Repository navigation
fix: keep npm publishes off the Takumi Guard proxy (#469) #2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | ||
| 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 | ||