Skip to content

fix: keep npm publishes off the Takumi Guard proxy (#469) #2

fix: keep npm publishes off the Takumi Guard proxy (#469)

fix: keep npm publishes off the Takumi Guard proxy (#469) #2

Workflow file for this run

name: Release

Check failure on line 1 in .github/workflows/release.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/release.yml

Invalid workflow file

(Line: 461, Col: 11): 'NPM_CONFIG_REGISTRY' is already defined
on:
workflow_call:
inputs:
dot_env_path:
default: .env
required: false
type: string
environment:
# Exported as WB_ENV. Releases build production artifacts, hence the default.
default: production
required: false
type: string
github_hosted_runner:
required: false
type: boolean
ignore_renovate:
required: false
type: boolean
runs_on:
required: false
type: string
skip_build:
required: false
type: boolean
target_organization:
required: false
type: string
trigger_deploy_workflow:
description: 'Workflow file to trigger after a new release (e.g., deploy-production.yml). Leave empty to disable.'
required: false
type: string
secrets:
DISCORD_WEBHOOK_URL:
required: false
DOT_ENV:
required: false
FNOX_AGE_KEY:
description: age secret key to decrypt fnox.toml secrets
required: false
GH_TOKEN:
required: true
TAKUMI_GUARD_TOKEN:
description: auth token for the Takumi Guard malicious-package-blocking registry proxy (used to generate .npmrc)
required: false
VERDACCIO_TOKEN:
description: auth token for the private Verdaccio registry (used to generate .npmrc)
required: false
jobs:
release:
if: ${{ (!inputs.target_organization || github.repository_owner == inputs.target_organization) && (!inputs.ignore_renovate || (github.event.head_commit.author.username != 'renovate-bot' && github.event.head_commit.author.username != 'renovate[bot]')) }}
# Serialize releases per repository (not per ref — two refs can point at the same
# commit): with overlapping runs, a run could observe the tag another run just
# published at its own commit and re-dispatch that release's deploy.
concurrency:
group: release-${{ github.repository }}
cancel-in-progress: false
# The default queue (`single`) cancels an already-pending run when another one queues,
# silently dropping a release; keep every queued release.
queue: max
runs-on: ${{ (inputs.runs_on && fromJSON(inputs.runs_on)) || ((!github.event.repository.private || inputs.github_hosted_runner) && 'ubuntu-latest') || fromJSON('["self-hosted", "medium"]') }}
env:
FORCE_COLOR: 3
NODE_OPTIONS: --max-old-space-size=4096
# arguments
# FNOX_AGE_KEY is deliberately NOT job-wide: like VERDACCIO_TOKEN, it is provided
# step-scoped only to the steps that actually run fnox (the check-fnox-secrets action and
# the consumer script steps), so dependency lifecycle scripts run by the installs and the
# lifecycle replay below can never read the long-lived age identity (#458).
HAS_DISCORD_WEBHOOK_URL: ${{ !!secrets.DISCORD_WEBHOOK_URL }}
HAS_DOT_ENV: ${{ !!secrets.DOT_ENV }}
# Either registry token triggers the .npmrc generation/cleanup; the hardened install
# path (scripts-disabled credentialed install + tokenless lifecycle replay) is instead
# keyed on the per-package-manager hardened-install output of the env-info step.
HAS_REGISTRY_TOKEN: ${{ !!secrets.VERDACCIO_TOKEN || !!secrets.TAKUMI_GUARD_TOKEN }}
HAS_TAKUMI_GUARD_TOKEN: ${{ !!secrets.TAKUMI_GUARD_TOKEN }}
# Non-secret signal for the `if:` gates below; the VERDACCIO_TOKEN and TAKUMI_GUARD_TOKEN
# secrets themselves are provided step-scoped only to the steps whose package-manager
# invocations expand them from the generated .npmrc.
HAS_VERDACCIO_TOKEN: ${{ !!secrets.VERDACCIO_TOKEN }}
# Defined-but-empty defaults: yarn1 crashes with "Failed to replace env in config" when a
# .npmrc references an UNDEFINED env var (an empty one is fine), so steps that are not
# given the secrets still need the variables to exist while the generated .npmrc is on
# disk (the Takumi Guard proxy also serves anonymously, so an empty token still installs).
TAKUMI_GUARD_TOKEN: ''
VERDACCIO_TOKEN: ''
# The `||` also covers a caller passing an EMPTY string (e.g. an unset vars.* reference),
# which would override the input's declared default.
WB_ENV: ${{ inputs.environment || 'production' }}
steps:
- name: Clear stale skip-worktree bits left by a crashed previous run
uses: WillBooster/reusable-workflows/.github/actions/clear-stale-skip-worktree@main
- uses: actions/checkout@v7.0.0
# The secret is passed via env and written with printf so that quotes or backslashes in
# its value cannot break out of the shell command.
- if: ${{ env.HAS_DOT_ENV == 'true' }}
run: printf '%s\n' "$DOT_ENV" > "$DOT_ENV_PATH"
env:
DOT_ENV: ${{ secrets.DOT_ENV }}
DOT_ENV_PATH: ${{ inputs.dot_env_path }}
- name: Check version files
id: check-ver
run: |
if [[ -f .tool-versions || -f mise.toml || -f .node-version || -f .python-version ]]; then
echo "exist-any-version=1" >> $GITHUB_OUTPUT
fi
if [[ -f .node-version ]] || \
([[ -f .tool-versions ]] && grep -qE '^(node|nodejs)[[:space:]]' .tool-versions) || \
([[ -f mise.toml ]] && grep -qE '^[[:space:]]*(node|nodejs)[[:space:]]*=' mise.toml); then
echo "exist-nodejs-version=1" >> $GITHUB_OUTPUT
fi
- if: ${{ !steps.check-ver.outputs.exist-nodejs-version }}
uses: actions/setup-node@v7.0.0
with:
node-version: '24.18.0'
check-latest: true
- if: ${{ steps.check-ver.outputs.exist-any-version && runner.environment == 'self-hosted' }}
name: Setup isolated GPG home
run: |
GNUPGHOME="$(mktemp -d "$RUNNER_TEMP/gnupg.XXXXXX")"
chmod 700 "$GNUPGHOME"
echo "GNUPGHOME=$GNUPGHOME" >> "$GITHUB_ENV"
- if: ${{ steps.check-ver.outputs.exist-any-version }}
uses: jdx/mise-action@v4.2.1
with:
cache: ${{ toJSON(runner.environment != 'self-hosted') }}
- name: Ensure fnox secrets are resolvable
uses: WillBooster/reusable-workflows/.github/actions/check-fnox-secrets@main
env:
# Empty when the caller does not provide the secret, which keeps fnox-less consumers unaffected.
FNOX_AGE_KEY: ${{ secrets.FNOX_AGE_KEY }}
- name: Show environment information
id: env-info
# https://stackoverflow.com/a/677212
run: |
echo "WB_ENV: $WB_ENV"
echo "node: $(node -v)"
echo "npm: $(npm -v)"
if [[ -f bunfig.toml ]] && \
(([[ -f .tool-versions ]] && grep -qE '^bun[[:space:]]' .tool-versions) || \
([[ -f mise.toml ]] && grep -qE '^[[:space:]]*bun[[:space:]]*=' mise.toml)); then
echo "bun: $(bun -v)"
echo "runner=bun" >> $GITHUB_OUTPUT
NPMRC_AWARE=1
else
YARN=$(yarn -v); echo "yarn: $YARN"
if [[ "$YARN" == "1."* ]]; then
echo "yarn-dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
echo "skip-scripts-option=--ignore-scripts" >> $GITHUB_OUTPUT
NPMRC_AWARE=1
elif [[ "$YARN" == "2."* ]]; then
: # Yarn 3 renamed Yarn 2's --skip-builds to --mode=skip-build.
echo "skip-scripts-option=--skip-builds" >> $GITHUB_OUTPUT
else
echo "skip-scripts-option=--mode=skip-build" >> $GITHUB_OUTPUT
fi
echo "runner=yarn" >> $GITHUB_OUTPUT
fi
: # Verdaccio credentials matter to every package manager, but the Guard token only
: # functions where .npmrc is read (bun/yarn1): a Guard-only Yarn Berry run keeps the
: # ordinary install path — there is no credential to protect, and the hardened
: # path's plugin stripping would break plugin-provided resolvers during its first,
: # replay-less install.
if [[ "$HAS_VERDACCIO_TOKEN" == "true" || ("$HAS_TAKUMI_GUARD_TOKEN" == "true" && -n "${NPMRC_AWARE:-}") ]]; then
echo "hardened-install=true" >> $GITHUB_OUTPUT
fi
- if: ${{ steps.env-info.outputs.yarn-dir }}
uses: actions/cache@v6.1.0
with:
path: ${{ steps.env-info.outputs.yarn-dir }}
# Don't use **/yarn.lock because it scans yarn.lock in node_modules
# c.f. https://github.com/AllanChain/blog/issues/98
key: ${{ runner.os }}-yarn-v1-${{ hashFiles('yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-v1-
# ${VERDACCIO_TOKEN} / ${TAKUMI_GUARD_TOKEN} are written literally; npm/bun/yarn1 expand
# them from the environment at install time, so no plaintext token lands on disk. The file
# goes into the workspace, NOT ~/.npmrc: a persistent ~/.npmrc would crash npm/yarn1 in any
# other job on the same self-hosted runner that leaves the variables undefined. The managed
# lines are rewritten authoritatively so stale entries never win over the current tokens,
# and the file is hidden from git (a root-scoped exclude entry, or skip-worktree when the
# consumer tracks .npmrc) so commit-and-push workflows cannot commit it. Yarn Berry ignores
# .npmrc; Berry consumers must reference ${VERDACCIO_TOKEN} from their committed
# .yarnrc.yml instead (and Takumi Guard does not apply to Berry installs). Guard covers
# newly RESOLVED public packages: package managers may download already-locked packages
# from the resolved URL recorded in the lockfile (Yarn 1 always does), bypassing the proxy.
- if: ${{ env.HAS_REGISTRY_TOKEN == 'true' }}
name: Generate .npmrc for the private Verdaccio registry and Takumi Guard
run: |
: # Recover state a crashed previous run may have left behind (the always() cleanup
: # cannot run when the runner dies): clear a stale skip-worktree bit and restore the
: # committed content before regenerating.
if git ls-files --error-unmatch -- .npmrc > /dev/null 2>&1; then
git update-index --no-skip-worktree -- .npmrc 2> /dev/null || true
git checkout -- .npmrc 2> /dev/null || true
fi
NPMRC_TMP=$(mktemp "$RUNNER_TEMP/npmrc.XXXXXX")
if [[ -e .npmrc ]]; then
: # Strip only the lines this run rewrites below (keyed on the present tokens), so a
: # committed custom registry= line or Verdaccio config survives runs without the
: # corresponding token ('$^' never matches, keeping every other line).
FILTER='$^'
if [[ "$HAS_TAKUMI_GUARD_TOKEN" == "true" ]]; then
FILTER="$FILTER|^registry=|^//npm\.flatt\.tech/:_authToken"
fi
if [[ "$HAS_VERDACCIO_TOKEN" == "true" ]]; then
FILTER="$FILTER|^@willbooster-private:registry|^//verdaccio-production-e389\.up\.railway\.app/:_authToken"
fi
grep -vE "$FILTER" .npmrc > "$NPMRC_TMP" || true
: # Some grep implementations do not add the final newline missing from the last line.
if [[ -s "$NPMRC_TMP" && -n "$(tail -c1 "$NPMRC_TMP")" ]]; then echo >> "$NPMRC_TMP"; fi
fi
if [[ "$HAS_TAKUMI_GUARD_TOKEN" == "true" ]]; then
: # Route default-registry installs through the Takumi Guard proxy, which blocks
: # known-malicious packages (https://flatt.tech/takumi/features/guard). Scoped
: # registries (e.g. @willbooster-private) deliberately keep their own registry.
echo 'registry=https://npm.flatt.tech/' >> "$NPMRC_TMP"
echo '//npm.flatt.tech/:_authToken=${TAKUMI_GUARD_TOKEN}' >> "$NPMRC_TMP"
fi
if [[ "$HAS_VERDACCIO_TOKEN" == "true" ]]; then
echo '@willbooster-private:registry=https://verdaccio-production-e389.up.railway.app/' >> "$NPMRC_TMP"
echo '//verdaccio-production-e389.up.railway.app/:_authToken=${VERDACCIO_TOKEN}' >> "$NPMRC_TMP"
fi
: # Replace .npmrc with mv instead of writing into it, so a consumer-committed symlink
: # cannot redirect the managed lines into its target (e.g. a persistent home npmrc).
rm -f .npmrc
mv "$NPMRC_TMP" .npmrc
: # Ignore rules never hide tracked files, so hide a committed .npmrc from the
: # commit-and-push workflows with skip-worktree (the dotenv-hide pattern shared by these workflows).
if git ls-files --error-unmatch -- .npmrc > /dev/null 2>&1; then
git update-index --skip-worktree -- .npmrc
else
if ! grep -qxF '/.npmrc' .git/info/exclude 2>/dev/null; then
: # A persistent exclude file may end without a newline; a direct append would
: # merge into its last rule and leave the generated file unhidden.
if [[ -s .git/info/exclude && -n "$(tail -c1 .git/info/exclude)" ]]; then echo >> .git/info/exclude; fi
echo '/.npmrc' >> .git/info/exclude
fi
fi
# Yarn Berry loads `.yarnrc.yml` plugins during every yarn command, including the
# credentialed install below, so a repository-committed plugin is config-driven code
# execution that can read the step-scoped registry tokens (#453). Strip the top-level
# `plugins:` block for the credentialed install only and restore the original file
# right after it, so plugins still apply to the tokenless lifecycle replay. Residual
# risk (out of scope here): the committed yarnPath release in .yarn/releases/ also
# executes during the credentialed install.
- if: ${{ steps.env-info.outputs.hardened-install == 'true' && steps.env-info.outputs.runner != 'bun' && steps.env-info.outputs.skip-scripts-option != '--ignore-scripts' }}
name: Remove plugins from .yarnrc.yml for the credentialed install
run: |
if [[ ! -f .yarnrc.yml ]]; then exit 0; fi
: # Recover the committed content a crashed previous run may have left sanitized
: # (the always() restore cannot run when the runner dies, and persistent
: # self-hosted workspaces keep the worktree between runs).
if git ls-files --error-unmatch -- .yarnrc.yml > /dev/null 2>&1; then
git checkout -- .yarnrc.yml 2> /dev/null || true
fi
YARNRC_BACKUP=$(mktemp "$RUNNER_TEMP/yarnrc.XXXXXX")
cp .yarnrc.yml "$YARNRC_BACKUP"
echo "YARNRC_BACKUP=$YARNRC_BACKUP" >> "$GITHUB_ENV"
: # Drop the top-level `plugins:` block (from `plugins:` up to the next top-level
: # key; column-0 comments do not end the block); keep every other line untouched.
YARNRC_TMP=$(mktemp "$RUNNER_TEMP/yarnrc-sanitized.XXXXXX")
awk '/^plugins:/ { skip = 1; next } skip && /^[^[:space:]#]/ { skip = 0 } !skip' "$YARNRC_BACKUP" > "$YARNRC_TMP"
: # Replace with mv instead of writing into the file, so a committed symlink cannot
: # redirect the write (the .npmrc generation pattern).
rm -f .yarnrc.yml
mv "$YARNRC_TMP" .yarnrc.yml
# With a registry token (Verdaccio and/or Takumi Guard), resolve and download with
# lifecycle scripts disabled so repository- or dependency-defined preinstall/postinstall
# can never read the step-scoped tokens (#449); the tokenless step below replays the
# skipped scripts.
- name: Install dependencies
run: |
if [[ "${{ steps.env-info.outputs.runner }}" == "bun" ]]; then
if [[ "${{ steps.env-info.outputs.hardened-install }}" == "true" ]]; then
bun install --ignore-scripts
else
# Because bun sometimes fails to install private repos (but, update is okay)
bun install
fi
elif [[ "${{ steps.env-info.outputs.hardened-install }}" == "true" ]]; then
: # No lifecycle scripts run here, so a single retry only covers transient network
: # failures; a reproducible failure is a resolution, auth or lockfile error.
${{ steps.env-info.outputs.runner }} install ${{ steps.env-info.outputs.skip-scripts-option }} || \
${{ steps.env-info.outputs.runner }} install ${{ steps.env-info.outputs.skip-scripts-option }}
else
: # Retry the same install first: this is the only network-bound step without a retry, and
: # letting a transient failure reach the fallback would skip every lifecycle script.
${{ steps.env-info.outputs.runner }} install || ${{ steps.env-info.outputs.runner }} install || {
: # Only a reproducible failure reaches here, which keeps postinstall failures tolerated
: # (consumers rely on it) while resolution, auth and lockfile errors still fail the job.
echo "::warning::Install failed twice; retrying without lifecycle scripts"
${{ steps.env-info.outputs.runner }} install ${{ steps.env-info.outputs.skip-scripts-option }}
}
fi
env:
# Only bun/yarn1 read the Guard lines from .npmrc; Yarn Berry ignores .npmrc, so the
# token would give its repo-controlled yarnPath executable a secret with zero
# installation function — Berry installs get the defined-but-empty job-level value.
TAKUMI_GUARD_TOKEN: ${{ (steps.env-info.outputs.runner == 'bun' || steps.env-info.outputs.skip-scripts-option == '--ignore-scripts') && secrets.TAKUMI_GUARD_TOKEN || '' }}
VERDACCIO_TOKEN: ${{ secrets.VERDACCIO_TOKEN }}
# Yarn 1 delegates execution to a repository-controlled `yarn-path` binary BEFORE
# --ignore-scripts applies, so bypass it exactly when this install is credentialed
# (hardened) — a tokenless install has no secret to protect and keeps its pinned
# wrapper. Empty means "honor yarn-path" (yarn1's boolifyWithDefault treats '' as
# unset); Berry repos always keep their committed yarnPath — it IS the Berry binary —
# and are tokenless here anyway.
YARN_IGNORE_PATH: ${{ (steps.env-info.outputs.hardened-install == 'true' && steps.env-info.outputs.skip-scripts-option == '--ignore-scripts') && '1' || '' }}
# always(): the original .yarnrc.yml must come back even when the credentialed
# install fails, both for the tokenless lifecycle replay below and so the sanitized
# file can never be committed by later steps or outlive the job on persistent
# self-hosted workspaces (YARNRC_BACKUP is only set when the step above sanitized).
- if: ${{ always() && env.YARNRC_BACKUP }}
name: Restore the original .yarnrc.yml
run: |
rm -f .yarnrc.yml
mv "$YARNRC_BACKUP" .yarnrc.yml
# Replay the lifecycle scripts the credentialed install skipped, in an environment without
# the registry tokens (the job-level empty values keep ${VERDACCIO_TOKEN} and
# ${TAKUMI_GUARD_TOKEN} references in .npmrc/.yarnrc.yml expanding to '' instead of
# erroring). Every package is already in the package manager's cache from the step above,
# so the private registry is not contacted (and Takumi Guard serves anonymously anyway).
- if: ${{ steps.env-info.outputs.hardened-install == 'true' }}
name: Run dependency lifecycle scripts without registry credentials
run: |
if [[ "${{ steps.env-info.outputs.runner }}" == "bun" ]]; then
: # A repeated `bun install` is a no-op that does not replay skipped scripts, so
: # force a full cache-hit re-link.
rm -rf node_modules
bun install || {
: # Keep postinstall failures tolerated (consumers rely on it) while still leaving
: # a complete node_modules behind — e.g. a repository postinstall that loads fnox
: # secrets cannot decrypt here because FNOX_AGE_KEY is deliberately step-scoped away.
echo "::warning::Lifecycle scripts failed; completing the install without them"
rm -rf node_modules
bun install --ignore-scripts
}
else
if [[ "${{ steps.env-info.outputs.skip-scripts-option }}" == "--ignore-scripts" ]]; then
: # yarn1 reports "Already up-to-date" and skips scripts unless node_modules is
: # removed; Yarn Berry instead tracks unbuilt packages and rebuilds them itself.
rm -rf node_modules
fi
${{ steps.env-info.outputs.runner }} install || {
: # Keep postinstall failures tolerated (consumers rely on it) while still leaving
: # a complete node_modules behind.
echo "::warning::Lifecycle scripts failed; completing the install without them"
${{ steps.env-info.outputs.runner }} install ${{ steps.env-info.outputs.skip-scripts-option }}
}
fi
- if: ${{ !inputs.skip_build }}
name: Build
run: if [[ "$(cat package.json)" == *'"build":'* ]]; then ${{ steps.env-info.outputs.runner }} run build; fi
env:
# The consumer build script loads env vars through wb/fnox (step-scoped; see the fnox
# check step above).
FNOX_AGE_KEY: ${{ secrets.FNOX_AGE_KEY }}
# After waiting in the concurrency queue, a run can be older than the branch head a
# later run already released from; releasing from the stale checkout could publish a
# lower version after a higher one. GitHub does not guarantee queue ordering, so skip
# whenever the remote branch head has moved past this run's commit.
- name: Check whether this run is stale
id: stale-check
# Branch-triggered runs only: a tag/dispatch ref has no refs/heads entry. A missing
# remote head means the branch was deleted — a deletion push still triggers the
# workflow with GITHUB_SHA reset to the default branch, which must never be
# released. ls-remote's status is read un-piped with --exit-code (2 = ref absent;
# anything else non-zero is a transport error, retried once then failed loudly —
# nothing has been released yet, so a re-run recovers cleanly). A moved head marks
# the run stale only when a successor run exists for the new head: renovate
# branch-automerges and [skip ci] pushes move the head WITHOUT creating a run, and
# skipping then would silently drop this commit's release.
run: |
if [[ "$GITHUB_REF" == refs/heads/* ]]; then
ls_head() { git ls-remote --exit-code origin "refs/heads/$GITHUB_REF_NAME"; }
LS_OUT=$(ls_head) && rc=0 || rc=$?
if [[ $rc -ne 0 && $rc -ne 2 ]]; then
sleep 5
LS_OUT=$(ls_head) && rc=0 || rc=$?
fi
if [[ $rc -ne 0 && $rc -ne 2 ]]; then
echo "::error::Could not read refs/heads/$GITHUB_REF_NAME on origin (git ls-remote exited $rc); nothing has been released — re-run the job."
exit 1
fi
if [[ $rc -eq 2 ]]; then
echo "::warning::Skipping the release: $GITHUB_REF_NAME was deleted."
echo "stale=true" >> "$GITHUB_OUTPUT"
else
REMOTE_HEAD=$(printf '%s' "$LS_OUT" | cut -f1)
if [[ "$REMOTE_HEAD" != "$GITHUB_SHA" ]]; then
: # A gh failure must not silently disable this guard (nor silently skip the
: # release): retry once, then fail loudly — nothing has been released yet.
run_count() { gh run list --commit "$REMOTE_HEAD" --workflow "$GITHUB_WORKFLOW" --json databaseId --jq length; }
SUCCESSOR_RUNS=$(run_count) || { sleep 5; SUCCESSOR_RUNS=$(run_count); } || {
echo "::error::Could not list workflow runs for $REMOTE_HEAD; nothing has been released — re-run the job."
exit 1
}
if [[ "$SUCCESSOR_RUNS" != "0" ]]; then
echo "::warning::Skipping the release: $GITHUB_REF_NAME moved to $REMOTE_HEAD and a newer run exists for it."
echo "stale=true" >> "$GITHUB_OUTPUT"
fi
fi
fi
fi
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
# This baseline feeds the new-release check that gates the deploy trigger and the
# Discord notification, so skip it (and its fetch cost) when neither is configured.
- if: ${{ steps.stale-check.outputs.stale != 'true' && (inputs.trigger_deploy_workflow || env.HAS_DISCORD_WEBHOOK_URL == 'true') }}
name: Record the tags existing before the release
id: pre-release-tag
# The checkout is shallow and tag-less, so fetch the tags first (`--unshallow` on a
# shallow clone so the refs resolve; it fails on a complete repository, e.g.
# persistent self-hosted workspaces, hence the conditional). Retry once, then FAIL:
# nothing has been published yet, and an unknown baseline could otherwise attribute
# a pre-existing tag to this run (e.g. a re-run of an already-released commit) and
# re-dispatch its deploy.
run: |
# --prune-tags drops runner-local tags absent from the remote (persistent
# self-hosted workspaces can retain a tag from a run whose push failed), so the
# baseline reflects the remote tag namespace.
fetch_tags() {
if [[ "$(git rev-parse --is-shallow-repository)" == "true" ]]; then
git fetch --unshallow --prune --prune-tags --tags --force
else
git fetch --prune --prune-tags --tags --force
fi
}
fetch_tags || { sleep 10; fetch_tags; }
git tag --list | sort > "$RUNNER_TEMP/tags-before-release.txt"
- if: ${{ steps.stale-check.outputs.stale != 'true' }}
name: Release
run: |
if [[ "$(cat package.json)" == *'"release":'* ]]; then
${{ steps.env-info.outputs.runner }} run release
else
# Because the latest semantic-release doesn't work on Bun
node node_modules/semantic-release/bin/semantic-release.js
fi
env:
# The consumer release script loads env vars through wb/fnox (step-scoped; see the
# fnox check step above). Known residual risk, same as VERDACCIO_TOKEN here:
# `wb release` clean-reinstalls with lifecycle scripts enabled while this step's
# credentials are in the environment (shared#1003).
FNOX_AGE_KEY: ${{ secrets.FNOX_AGE_KEY }}
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} # PAT with contents:write
# Pin the DEFAULT publish registry back to npmjs: the generated .npmrc may point
# `registry=` at the Takumi Guard proxy, which is an install-side cache that rejects
# `npm publish` with 405; semantic-release's npm plugin resolves the registry as
# `publishConfig.registry || env.NPM_CONFIG_REGISTRY || rc(.npmrc)`, so packages
# publishing to Verdaccio (which declare publishConfig.registry) are unaffected.
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
# semantic-release's npm plugin reads the NPM_TOKEN env var for registry auth.
NPM_TOKEN: ${{ secrets.VERDACCIO_TOKEN }}
# Takumi Guard is an install proxy, not a publish registry. publishConfig.registry
# still takes precedence for packages intentionally published elsewhere.
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
# The release script's npm invocations expand these tokens from the generated .npmrc.
TAKUMI_GUARD_TOKEN: ${{ secrets.TAKUMI_GUARD_TOKEN }}
VERDACCIO_TOKEN: ${{ secrets.VERDACCIO_TOKEN }}
HUSKY: 0
LEFTHOOK: 0
# !cancelled(): semantic-release can fail AFTER pushing its tag (e.g. a later publish
# plugin); the pushed release must still be detected and deployed — a re-run cannot
# re-detect it, and the job still fails via the Release step's own outcome.
- if: ${{ !cancelled() && (inputs.trigger_deploy_workflow || env.HAS_DISCORD_WEBHOOK_URL == 'true') && steps.pre-release-tag.outcome == 'success' }}
name: Check for new release tag
id: post-release-tag
# semantic-release creates the tag locally before pushing, so tolerate a failed
# fetch here instead of failing the job after a successful release. The new tag is
# detected as a set difference against the baseline AND must point at the commit
# this run built, so a tag published by an overlapping run or an already-released
# commit can never re-trigger a deploy. The set difference (not `git describe`,
# which prefers annotated tags and can hide semantic-release's lightweight tag
# behind an annotated one on the same commit) identifies the created tag. Tag names
# may contain shell metacharacters, so they reach later scripts only via env.
# A new tag counts as this run's when its commit is GITHUB_SHA or a descendant of it
# (release scripts like `npm version` tag a commit they create on top of GITHUB_SHA);
# pre-existing tags point at ancestors and never match. Multiple tags are kept
# space-joined (refnames cannot contain spaces): multi-semantic-release tags every
# workspace package on the same commit. The qualified ref + --end-of-options keeps
# tag names starting with '-' from being parsed as options.
run: |
check_remote_tag() {
git ls-remote --exit-code --tags origin "refs/tags/$1" > /dev/null 2>&1
}
git fetch --prune --prune-tags --tags --force || true
git tag --list | sort > "$RUNNER_TEMP/tags-after-release.txt"
NEW_TAGS=''
NEW_COMMIT=''
UNRESOLVED_TAGS=''
while IFS= read -r tag; do
TAG_COMMIT=$(git rev-parse --verify --end-of-options "refs/tags/$tag^{commit}" 2>/dev/null) || continue
git merge-base --is-ancestor "$GITHUB_SHA" "$TAG_COMMIT" 2>/dev/null || continue
# Only remotely published tags count: a release script may tag locally and then
# fail to push, and `gh workflow run --ref` cannot resolve a local-only tag.
# ls-remote --exit-code returns 2 for a missing ref; any other failure is a
# transport error, which must not silently drop a published release (a re-run
# cannot re-detect it), so retry once and then fail loudly.
if check_remote_tag "$tag"; then
:
else
rc=$?
if [[ $rc -ne 2 ]]; then
sleep 5
check_remote_tag "$tag" && rc=0 || rc=$?
fi
if [[ $rc -eq 2 ]]; then continue; fi
if [[ $rc -ne 0 ]]; then
# Keep scanning: one unverifiable tag must not swallow the side effects of
# tags that DID verify (a re-run cannot re-detect any of them).
echo "::error::Could not verify refs/tags/$tag on origin (git ls-remote exited $rc); if it is published, dispatch the deploy manually with \`gh workflow run <workflow> --ref $tag\`."
UNRESOLVED_TAGS="${UNRESOLVED_TAGS:+$UNRESOLVED_TAGS }$tag"
continue
fi
fi
NEW_TAGS="${NEW_TAGS:+$NEW_TAGS }$tag"
NEW_COMMIT=${NEW_COMMIT:-$TAG_COMMIT}
done < <(comm -13 "$RUNNER_TEMP/tags-before-release.txt" "$RUNNER_TEMP/tags-after-release.txt")
echo "tags=$NEW_TAGS" >> "$GITHUB_OUTPUT"
echo "commit=$NEW_COMMIT" >> "$GITHUB_OUTPUT"
if [[ -n "$NEW_TAGS" ]]; then
echo "created=true" >> "$GITHUB_OUTPUT"
fi
if [[ -n "${UNRESOLVED_TAGS:-}" ]]; then
exit 1
fi
# outputs.created is still written when the step later fails on an unverifiable tag,
# and !cancelled() lets the verified tags' side effects proceed.
- if: ${{ !cancelled() && inputs.trigger_deploy_workflow && steps.post-release-tag.outputs.created == 'true' }}
name: Trigger deploy workflow
# Word splitting on $NEW_TAGS is intended: refnames cannot contain spaces. Each tag
# is retried once and a failure does not abort the remaining tags. NOTE: a re-run of
# this job cannot re-detect the tags (its baseline already contains them), so if
# dispatches still fail, recover manually with
# `gh workflow run <deploy workflow> --ref <tag>`.
run: |
STATUS=0
for tag in $NEW_TAGS; do
gh workflow run "$DEPLOY_WORKFLOW" --ref "$tag" || gh workflow run "$DEPLOY_WORKFLOW" --ref "$tag" || {
echo "::error::Failed to dispatch $DEPLOY_WORKFLOW for $tag; run \`gh workflow run $DEPLOY_WORKFLOW --ref $tag\` manually."
STATUS=1
}
done
exit $STATUS
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }} # PAT with contents:write + actions:write
DEPLOY_WORKFLOW: ${{ inputs.trigger_deploy_workflow }}
NEW_TAGS: ${{ steps.post-release-tag.outputs.tags }}
# Notify only when this run actually created a release; a build-only push used to
# re-announce the previous tag. Same hardened shape as deploy.yml's notifiers: the
# content is JSON-escaped by toJSON and inserted with printf so tag names cannot break
# the JSON or the shell, and --fail-with-body surfaces webhook rejections instead of
# silently losing the announcement. This step must stay AFTER the deploy trigger:
# under bash -e a webhook rejection fails the job, and the deploy dispatch is not
# recoverable by a re-run (the re-run's baseline already contains the tag).
# allowed_mentions is emptied because the tag name lands in Discord Markdown, and the
# commit link uses the SHA so tag names need no URL-encoding.
# !cancelled(): the announcement of a created release must still go out when a
# deploy dispatch failed (the implicit success() would skip it, unrecoverably).
- if: ${{ !cancelled() && env.HAS_DISCORD_WEBHOOK_URL == 'true' && steps.post-release-tag.outputs.created == 'true' }}
run: |
printf '{"username":"WillBooster Bot","avatar_url":"https://avatars.githubusercontent.com/u/98675783","allowed_mentions":{"parse":[]},"content":%s}' "$CONTENT_JSON" > "$RUNNER_TEMP/discord-payload.json"
: # wait=true makes Discord confirm the message was saved; without it an unsaved
: # message still returns success and the announcement is silently lost.
curl --retry 2 --fail-with-body -X POST -H "Content-Type: application/json" --data @"$RUNNER_TEMP/discord-payload.json" "$DISCORD_WEBHOOK_URL?wait=true"
rm -f "$RUNNER_TEMP/discord-payload.json"
env:
# The link targets the tagged commit, which release scripts like npm version may
# create on top of github.sha.
CONTENT_JSON: ${{ toJSON(format('[{0}]({1})の最新版 ([{2}]({1}/commit/{3})) をリリースしました。', github.event.repository.name, github.event.repository.html_url, steps.post-release-tag.outputs.tags, steps.post-release-tag.outputs.commit)) }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
# Delete only when the DOT_ENV secret was actually written by this run: dot_env_path
# defaults to .env, which some private repos commit.
- if: ${{ always() && env.HAS_DOT_ENV == 'true' }}
name: Delete credential files
run: rm -f -- "$DOT_ENV_PATH"
env:
DOT_ENV_PATH: ${{ inputs.dot_env_path }}
# Revert the generate step: remove the file when this run created it, or restore the
# committed content and clear the skip-worktree bit when the consumer tracks .npmrc; the
# `.git` mutations otherwise outlive the job on persistent self-hosted workspaces.
- if: ${{ always() && env.HAS_REGISTRY_TOKEN == 'true' }}
name: Clean up the generated .npmrc
run: |
if git ls-files --error-unmatch -- .npmrc > /dev/null 2>&1; then
git update-index --no-skip-worktree -- .npmrc 2> /dev/null || true
git checkout -- .npmrc 2> /dev/null || true
else
rm -f .npmrc
if [[ -f .git/info/exclude ]]; then
: # `grep -vx` exits 1 when every line matches, so tolerate it to keep `bash -e` green.
grep -vxF -- '/.npmrc' .git/info/exclude > .git/info/exclude.tmp || true
mv .git/info/exclude.tmp .git/info/exclude
fi
fi