From 4019d204df9a08938e6407ae4bb9990723db2028 Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Sun, 18 Oct 2026 07:38:27 -0500 Subject: [PATCH 1/3] chore: add the MIT LICENSE Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XRCpaD5QTPJsEjnhikoV2S --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..338c113 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 rcrd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 38fd4ec81c1295b667f57b03a7442126e0e44ccd Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Sun, 18 Oct 2026 07:46:12 -0500 Subject: [PATCH 2/3] docs: give the READMEs the Variable Lab structure, and add the contributing and development guides The root README takes the gh-actions title, the GitHub description, the actions and the reusable workflow as lists, Versioning, Contributing, License and the Signature. The Node action convention, the dependency cooldown and security linting move to docs/development.md. Each action's README takes its folder name as its title, then Usage, Inputs, Outputs and Requirements, with its concepts after them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XRCpaD5QTPJsEjnhikoV2S --- README.md | 60 +++----- actions/monorepo-preview-release/README.md | 159 ++++++++++----------- actions/preview-release/README.md | 155 ++++++++++---------- actions/railway-redeploy/README.md | 30 ++-- actions/setup-bun/README.md | 12 +- actions/setup-pnpm-bun/README.md | 28 ++-- actions/setup-pnpm/README.md | 58 ++++---- docs/contributing.md | 19 +++ docs/development.md | 72 ++++++++++ pnpm-workspace.yaml | 2 +- 10 files changed, 326 insertions(+), 269 deletions(-) create mode 100644 docs/contributing.md create mode 100644 docs/development.md diff --git a/README.md b/README.md index 1a52c0a..6394984 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,19 @@ -# Variable Lab β€” GitHub Actions +# gh-actions -Reusable GitHub Actions used across Variable Lab and Variable Land projects. +Reusable GitHub Actions for Variable Land. ## Actions -| Action | Description | -| --- | --- | -| [`preview-release`](./actions/preview-release/README.md) | Publish a per-PR preview release of a standalone library (single package, not a workspace) to npm (OIDC-first, with optional `NPM_TOKEN` fallback). The PR comment comes from the [`vland-bot`](https://bot.variable.land) server (`mode: server`, default) or from a separate job that never runs PR code (`mode: publish` + `mode: comment`, see [`preview-comment`](#reusable-workflows)). | -| [`monorepo-preview-release`](./actions/monorepo-preview-release/README.md) | Publish a per-PR preview release of changed packages in a pnpm monorepo to npm (OIDC-first, with optional `NPM_TOKEN` fallback). The PR comment comes from the [`vland-bot`](https://bot.variable.land) server (`mode: server`, default) or from a separate job that never runs PR code (`mode: publish` + `mode: comment`, see [`preview-comment`](#reusable-workflows)). | -| [`railway-redeploy`](./actions/railway-redeploy/README.md) | Trigger a redeploy of a Railway service via the Railway GraphQL API. Resolves the target service from `project_id` + `environment` + `service_name`, so no service ID lookup is needed. | -| [`setup-pnpm`](./actions/setup-pnpm/README.md) | **Deprecated:** install node and pnpm with mise instead ([migration](./actions/setup-pnpm/README.md#migrating-to-mise)). Install pnpm, upgrade npm, cache the pnpm store, and install dependencies. | -| [`setup-bun`](./actions/setup-bun/README.md) | Install Bun pinned to the version in `.bun-version`. | -| [`setup-pnpm-bun`](./actions/setup-pnpm-bun/README.md) | **Deprecated:** install node, pnpm and bun with mise instead ([migration](./actions/setup-pnpm/README.md#migrating-to-mise)). Combined `setup-pnpm` + `setup-bun` for workflows that need both runtimes. | +- [`preview-release`](actions/preview-release): publishes a per-PR preview release of a standalone library, a single package outside a workspace, to npm, with OIDC first and an optional `NPM_TOKEN` fallback. The PR comment comes from the [`vland-bot`](https://bot.variable.land) server (`mode: server`, the default) or from a separate job that never runs PR code (`mode: publish` and `mode: comment`, with [`preview-comment`](#reusable-workflows)). +- [`monorepo-preview-release`](actions/monorepo-preview-release): publishes a per-PR preview release of the changed packages in a pnpm monorepo to npm, with OIDC first and an optional `NPM_TOKEN` fallback. The PR comment comes from the [`vland-bot`](https://bot.variable.land) server (`mode: server`, the default) or from a separate job that never runs PR code (`mode: publish` and `mode: comment`, with [`preview-comment`](#reusable-workflows)). +- [`railway-redeploy`](actions/railway-redeploy): redeploys a Railway service through the Railway GraphQL API. It finds the service from `project_id`, `environment` and `service_name`, so it needs no service ID. +- [`setup-pnpm`](actions/setup-pnpm): installs pnpm, upgrades npm, caches the pnpm store and installs the dependencies. **Deprecated:** install node and pnpm with mise instead ([migration](actions/setup-pnpm/README.md#migrating-to-mise)). +- [`setup-bun`](actions/setup-bun): installs Bun at the version in `.bun-version`. +- [`setup-pnpm-bun`](actions/setup-pnpm-bun): `setup-pnpm` and `setup-bun` in one step, for workflows that need both runtimes. **Deprecated:** install node, pnpm and bun with mise instead ([migration](actions/setup-pnpm/README.md#migrating-to-mise)). ## Reusable workflows -| Workflow | Description | -| --- | --- | -| [`preview-comment`](./.github/workflows/preview-comment.yml) | Job 2 of the two-job preview pattern: mints a `vland-bot` token (current repo, `pull-requests: write`) and upserts the preview-release PR comment from the `packages` output of a `mode: publish` job, without checking out or running PR code. Setup and caller example in the [`preview-release`](./actions/preview-release/README.md#two-job-previews) and [`monorepo-preview-release`](./actions/monorepo-preview-release/README.md#two-job-previews) READMEs. | +- [`preview-comment`](.github/workflows/preview-comment.yml): job 2 of the two-job preview pattern. It mints a `vland-bot` token for the current repo, with `pull-requests: write`, and upserts the preview release's PR comment from the `packages` output of a `mode: publish` job, without checking out or running PR code. The setup and a caller example are in the [`preview-release`](actions/preview-release/README.md#two-job-previews) and [`monorepo-preview-release`](actions/monorepo-preview-release/README.md#two-job-previews) READMEs. ```yml preview-comment: @@ -33,7 +29,7 @@ preview-comment: ## Versioning -Pin actions to a tag or commit SHA when you need stability: +Pin an action to a tag or a commit SHA when you need stability: ```yml uses: withvariablelab/gh-actions/actions/@ @@ -41,36 +37,14 @@ uses: withvariablelab/gh-actions/actions/@ `@main` tracks the latest changes on the default branch. -## Node action convention +## Contributing -Every Node-based action in this monorepo follows the same shape: +The [contributing guide](docs/contributing.md) covers issues and pull requests, and the [development guide](docs/development.md) covers setting up the repo and running its checks. -1. **`action.yml`** declares `runs.using: node24` and `main: dist/index.js`. -2. **Source** lives in `src/`. The bundled output (`dist/index.js`) is committed to the repo and runs directly on the GitHub Actions runner β€” no Docker build, no install at runtime. -3. **`mise.toml`** declares a `build` task that runs `ncc build src/index.ts -o dist`. Mise's incremental `sources`/`outputs` tracking skips the rebuild when nothing has changed, so `mise run build` does **not** catch a hand-edited `dist/`. The root **`check-dist`** task does: it force-rebuilds every `dist/` and fails if anything under `dist/` differs from the commit (including new files). -4. The **lefthook `pre-push` hook** runs `mise run check-dist` for any push that touches an action's `src/`, `dist/`, `package.json`, `package-lock.json` or `tsconfig.json`, and blocks the push if `dist/` is out of sync with `src/`. -5. CI (`Check actions dist sync`) runs `check-dist` on every PR. It is a required check, so bypassing the local hook still gets caught. -6. **Unit tests** live next to the code as `src/**/*.test.ts` and run with Node's built-in test runner (`node --test`, native type stripping, no extra dependencies): `mise run test` per action or at the root. The pre-push hook and `Check actions dist sync` run them too. -7. `preview-release` and `monorepo-preview-release` carry **byte-identical copies** of the comment-mode code (`src/comment.ts`, `src/types.ts`) and the shared tests (`src/comment.test.ts`, `src/core.test.ts`). Change both copies together; CI diffs them. +## License -To add a new Node action, copy the structure of `monorepo-preview-release` or `railway-redeploy`, adjust inputs and logic, and run `mise run build` (or `mise run check-dist`, which the pre-push hook also runs and which tells you to commit the dist change). +[MIT](LICENSE). -## Dependency cooldown - -New dependency versions must be at least 24 h old before they get in. Most malicious releases are pulled within the first hour. - -- **Root tooling** (pnpm): [`pnpm-workspace.yaml`](./pnpm-workspace.yaml) sets `minimumReleaseAge: 1440`, excluding `@variablelab/*` and `@rrlab/*`. pnpm (>= 10.16, pinned in `package.json` and `mise.toml`) refuses a version that is too new with `ERR_PNPM_NO_MATURE_MATCHING_VERSION`. -- **Action dependencies** (npm, one `package-lock.json` per action): npm has no persistent cooldown setting. When you add or update a dependency of an action, pass `--before` with a timestamp 24 h in the past so npm resolves only versions published before it: - - ```sh - cd actions/ - npm install @ --before="$(node -p 'new Date(Date.now() - 864e5).toISOString()')" - ``` - - The same flag works with `npm update`. Then run `mise run check-dist` and commit the rebuilt `dist/` together with the lockfile. -- **Global npm CLI** in `setup-pnpm` and `setup-pnpm-bun`: an exact version, bumped by hand to a release at least 24 h old (see the comment on the "Update npm" step). Both actions are deprecated in favor of mise, which uses the npm bundled with the locked Node. -- **Security updates**: once security updates are enabled in the repo settings, Dependabot ([`dependabot.yml`](./.github/dependabot.yml)) opens a PR for every new security alert in the root and in each action (version updates stay manual), and [`Dependency audit`](./.github/workflows/dependency-audit.yml) audits every lockfile each Monday, keeping an issue open while advisories remain. Dependabot does not rebuild `dist/`: on its PRs for an action, `Check actions dist sync` fails until someone runs `mise run check-dist` on the branch and pushes the rebuilt `dist/`. Its PRs also skip the 24 h cooldown (GitHub ignores `cooldown` for security updates, and Dependabot runs pnpm with `minimumReleaseAge=0`), so check the fixed version's publish date before merging one. - -## Security linting - -`Workflow audit` ([`workflow-audit.yml`](./.github/workflows/workflow-audit.yml)) runs [zizmor](https://docs.zizmor.sh) on every PR and push to `main` (config: [`.github/zizmor.yml`](./.github/zizmor.yml)). Third-party actions are pinned by SHA; `withvariablelab/*` is allowed at `@main`. Locally: `pipx run zizmor .`. +

+  Variable Land +

diff --git a/actions/monorepo-preview-release/README.md b/actions/monorepo-preview-release/README.md index 1fb5ddc..53b1ee7 100644 --- a/actions/monorepo-preview-release/README.md +++ b/actions/monorepo-preview-release/README.md @@ -1,34 +1,38 @@ -# Monorepo preview release +# monorepo-preview-release -GitHub Action that publishes a **per-PR preview release** of every changed package in a pnpm workspace and gets a sticky comment posted on the pull request with the published versions. - -For each PR: - -- The packages changed by the PR (and their workspace dependents) are detected via the GitHub PR files API. -- Each package gets its version bumped to `-git-` (prerelease). -- Each package is published to npm under the dist-tag `pr-`. -- Private packages (`"private": true`) are excluded. -- A package's **first ever publish** is a special case: npm force-assigns the `latest` dist-tag to it (and won't let that tag be removed), so the preview becomes the default `pnpm add ` target until a stable release re-points `latest`. The action flags these first-time publishes so the PR comment can warn about it. See [First-time publishes & `latest`](#first-time-publishes--latest). -- The PR comment is posted either by the `vland-bot` server (`mode: server`, the default) or by a second job that never runs PR code (`mode: publish` + `mode: comment`). See [Modes](#modes). +This action publishes a **per-PR preview release** of every changed package in a pnpm workspace, and gets a sticky comment with the published versions posted on the pull request. ## Modes -The `mode` input picks what a run does: +The PR comment is posted either by the `vland-bot` server (`mode: server`, the default) or by a second job that never runs PR code (`mode: publish` and `mode: comment`). The `mode` input picks what a run does: | `mode` | Publishes | Comments | Use it for | | --- | --- | --- | --- | -| `server` (default) | yes | via the `vland-bot` server: the action POSTs the result, authenticated with a GitHub OIDC token (audience `vland-bot`) | variableland repos. This is the behaviour from before modes existed. | -| `publish` | yes | no. Sets the [`packages`, `pr-number` and `head-sha` outputs](#outputs) instead | Job 1 of the [two-job pattern](#two-job-previews) | -| `comment` | no | yes, directly: upserts the comment with `github_token` | Job 2 of the two-job pattern, through the [`preview-comment` reusable workflow](../../.github/workflows/preview-comment.yml) | +| `server` (default) | yes | Through the `vland-bot` server: the action POSTs the result, authenticated with a GitHub OIDC token (audience `vland-bot`). | Repos that let the `vland-bot` server post the comment. It is the behavior from before modes existed. | +| `publish` | yes | No. It sets the [`packages`, `pr-number` and `head-sha` outputs](#outputs) instead. | Job 1 of the [two-job pattern](#two-job-previews). | +| `comment` | no | Yes, directly: it upserts the comment with `github_token`. | Job 2 of the two-job pattern, through the [`preview-comment` reusable workflow](../../.github/workflows/preview-comment.yml). | + +## Usage + +`mode: server`, the default, needs only the step and `github_token`: + +```yml +- name: πŸš€ Preview release + uses: withvariablelab/gh-actions/actions/monorepo-preview-release@main + with: + github_token: ${{ secrets.GITHUB_TOKEN }} +``` -## Two-job previews +For `publish` and `comment`, see [Two-job previews](#two-job-previews). -The `vland-bot` server exists so the bot's private key is never in a job that runs PR code (the preview job installs and builds the PR). The two-job pattern keeps that property without the server: +### Two-job previews -1. **`preview`** runs the PR code and publishes with npm OIDC (`mode: publish`). It has `id-token: write` and `contents: read`, no app key, and only hands the published package list to the next job as an output. -2. **`preview-comment`** (`needs: preview`) calls the reusable workflow [`preview-comment.yml`](../../.github/workflows/preview-comment.yml). That workflow never checks out or runs PR code: it mints a `vland-bot` installation token (current repo only, `pull-requests: write`) and runs this action with `mode: comment`, which validates the package list and upserts the comment. +The `vland-bot` server exists so the bot's private key is never in a job that runs PR code: the preview job installs and builds the PR. The two-job pattern keeps that property without the server: -The comment has the same layout and the same `` marker as the one the server posts, and it is posted by the same `vland-bot` app, so job 2 updates comments the server created earlier. +1. **`preview`** runs the PR code and publishes with npm OIDC (`mode: publish`). It has `id-token: write` and `contents: read`, no app key, and only hands the published package list to the next job, as an output. +2. **`preview-comment`** (`needs: preview`) calls the reusable workflow [`preview-comment.yml`](../../.github/workflows/preview-comment.yml). That workflow never checks out or runs PR code: it mints a `vland-bot` installation token, for the current repo only and with `pull-requests: write`, and runs this action with `mode: comment`, which validates the package list and upserts the comment. + +The comment has the same layout and the same `` marker as the one the server posts, and the same `vland-bot` app posts it, so job 2 updates comments the server created earlier. ```yml name: CI @@ -90,9 +94,9 @@ jobs: What the caller needs: -- The org (or repo) **variable** `VLAND_BOT_CLIENT_ID` and **secret** `VLAND_BOT_PRIVATE_KEY`. The reusable workflow reads `vars.VLAND_BOT_CLIENT_ID` itself: in a called workflow, `vars` resolves against the caller's repository. The secret is passed explicitly (no `secrets: inherit`). +- The org or repo **variable** `VLAND_BOT_CLIENT_ID` and **secret** `VLAND_BOT_PRIVATE_KEY`. The reusable workflow reads `vars.VLAND_BOT_CLIENT_ID` itself: in a called workflow, `vars` resolves against the caller's repository. The secret is passed explicitly, with no `secrets: inherit`. - `vland-bot` installed on the repository. -- `packages` travels **only** through `outputs:` and `with:`. Never interpolate `needs.preview.outputs.packages` (or any other output of the preview job) into a `run:` script: that job ran PR code, so its outputs are untrusted and would be a script injection. +- `packages` travels **only** through `outputs:` and `with:`. Never interpolate `needs.preview.outputs.packages`, or any other output of the preview job, into a `run:` script: that job ran PR code, so its outputs are untrusted and would be a script injection. `preview-comment.yml` inputs: @@ -103,52 +107,46 @@ What the caller needs: If `preview` fails or is skipped, `needs` skips `preview-comment` too. -## Usage - -`mode: server` (default), as used by variableland repos: - -```yml -- name: πŸš€ Preview release - uses: withvariablelab/gh-actions/actions/monorepo-preview-release@main - with: - github_token: ${{ secrets.GITHUB_TOKEN }} -``` - -For `publish` + `comment`, see [Two-job previews](#two-job-previews). - ## Inputs | Input | Required | Default | Description | | --- | --- | --- | --- | | `mode` | no | `server` | `server`, `publish` or `comment`. See [Modes](#modes). | -| `github_token` | yes | β€” | `server`/`publish`: token for the GitHub API (typically `${{ secrets.GITHUB_TOKEN }}`), used to list the PR's changed files. `comment`: token that can write PR comments (the `vland-bot` token minted by `preview-comment.yml`). | -| `npm_token` | no | β€” | `server`/`publish` only. npm classic/automation token. When set, enables the OIDC + token fallback mode (see below). | -| `publish_mode` | no | auto-detected | `server`/`publish` only. Override the [authentication mode](#authentication-modes): `token-only`, `oidc-only` or `oidc-with-token-fallback`. | -| `vland_bot_url` | no | `https://bot.variable.land` | `server` only. Base URL of the `vland-bot` server that receives the post-publish notification. | -| `packages` | in `comment` | β€” | `comment` only. The `packages` output of a `publish` run (JSON). | -| `app_slug` | in `comment` | β€” | `comment` only. Slug of the GitHub App behind `github_token` (`preview-comment.yml` passes the `app-slug` output of `actions/create-github-app-token`). Only a marker comment written by `[bot]` is updated. | +| `github_token` | yes | β€” | `server` and `publish`: a token for the GitHub API, usually `${{ secrets.GITHUB_TOKEN }}`, to list the PR's changed files. `comment`: a token that can write PR comments, the `vland-bot` token that `preview-comment.yml` mints. | +| `npm_token` | no | β€” | `server` and `publish` only. An npm classic or automation token. When set, it turns on the OIDC and token fallback mode (see [Authentication modes](#authentication-modes)). | +| `publish_mode` | no | detected | `server` and `publish` only. Overrides the [authentication mode](#authentication-modes): `token-only`, `oidc-only` or `oidc-with-token-fallback`. | +| `vland_bot_url` | no | `https://bot.variable.land` | `server` only. The base URL of the `vland-bot` server that gets the notification after the publish. | +| `packages` | in `comment` | β€” | `comment` only. The `packages` output of a `publish` run, as JSON. | +| `app_slug` | in `comment` | β€” | `comment` only. The slug of the GitHub App behind `github_token` (`preview-comment.yml` passes the `app-slug` output of `actions/create-github-app-token`). Only a marker comment written by `[bot]` is updated. | ## Outputs Set in `mode: publish` only: -| Output | Description | -| --- | --- | -| `packages` | JSON array with one entry per published package (`[]` when nothing changed). Fields below. | -| `pr-number` | Number of the pull request. | -| `head-sha` | Head commit SHA of the pull request. | +- `packages`: a JSON array with one entry per published package, or `[]` when nothing changed. Its fields are below. +- `pr-number`: the number of the pull request. +- `head-sha`: the head commit SHA of the pull request. -Each `packages` entry (the same payload `mode: server` POSTs to `vland-bot` at `POST /v1/github/preview-release`): +Each `packages` entry is the same payload that `mode: server` POSTs to `vland-bot` at `POST /v1/github/preview-release`: | Field | Type | Description | | --- | --- | --- | | `packageName` | `string` | The npm package name. | | `nextVersion` | `string` | The published preview version (`-git-`). | -| `firstTime` | `boolean` | `true` when this run published the package's very first version. npm makes that version `latest` (unavoidably β€” see below), so the comment warns that `pnpm add ` currently resolves to a preview build until a stable release re-points `latest`. | +| `firstTime` | `boolean` | `true` when this run published the package's first version ever. npm makes that version `latest`, and nothing can prevent it (see [First-time publishes & `latest`](#first-time-publishes--latest)), so the comment warns that `pnpm add ` resolves to a preview build until a stable release moves `latest`. | + +## Requirements + +In `server` and `publish` modes, the action runs `pnpm publish` itself, so the caller's job needs: + +- **`pnpm` on the `PATH`.** Install node and pnpm with mise through [`jdx/mise-action`](https://github.com/jdx/mise-action), as in the [two-job example](#two-job-previews). See [Migrating to mise](../setup-pnpm/README.md#migrating-to-mise). +- **A populated `node_modules`.** `pnpm install` has to run before the action, so lifecycle scripts (`prepack` or `prepare` running `pnpm build`, `tsdown` and the like) find their devDependencies. + +`comment` mode needs none of that: it runs in an empty job. ## Required permissions -`server` and `publish` (the job that runs the PR code): +`server` and `publish`, in the job that runs the PR code: ```yml permissions: @@ -158,59 +156,56 @@ permissions: `comment`: none from `GITHUB_TOKEN` (`permissions: {}`). It uses the app token passed as `github_token`, which needs `pull-requests: write` on the current repository. +## What a run publishes + +- The packages the PR changes, and their workspace dependents, are found through the GitHub API's list of the PR's files. +- Each package gets its version bumped to the prerelease `-git-`. +- Each package is published to npm under the dist-tag `pr-`. +- Private packages (`"private": true`) are left out. +- A package's **first publish ever** is a special case. npm gives it the `latest` dist-tag and won't let that tag be removed, so the preview becomes the default `pnpm add ` target until a stable release moves `latest`. The action flags these first publishes, so the PR comment can warn about it. See [First-time publishes & `latest`](#first-time-publishes--latest). + ## Authentication modes -`server` and `publish` pick one of three npm publishing modes automatically based on the workspace state and the inputs (override with `publish_mode`): +`server` and `publish` pick one of three npm publishing modes from the workspace's state and the inputs. `publish_mode` overrides the choice. -| Detected state | Mode | Behavior | -| --- | --- | --- | -| `.npmrc` already provides auth | `token-only` | Publishes using the existing `.npmrc` credentials. Never adds `--provenance`. | -| `npm_token` input is provided (and no `.npmrc` auth) | `oidc-with-token-fallback` | Publishes each package with OIDC + provenance via `pnpm publish --provenance`. If a given package's publish fails **and** the package is unpublished on npm, it falls back to `NPM_TOKEN` auth without provenance. | -| Neither of the above | `oidc-only` | Publishes with OIDC + provenance. Any failure is fatal. | +- **`token-only`**, when `.npmrc` already provides auth: publishes with the existing `.npmrc` credentials, and never adds `--provenance`. +- **`oidc-with-token-fallback`**, when the `npm_token` input is set and `.npmrc` has no auth: publishes each package with OIDC and provenance through `pnpm publish --provenance`. If a package's publish fails **and** that package has never been published to npm, it falls back to `NPM_TOKEN` auth without provenance. +- **`oidc-only`**, in every other case: publishes with OIDC and provenance. Any failure is fatal. -The `oidc-with-token-fallback` mode exists to handle **first-time publishes** of packages that don't yet have a Trusted Publisher configured on npm: those land via the token (no provenance), while every other package keeps its provenance attestation. +The `oidc-with-token-fallback` mode exists for the **first publishes** of packages that have no Trusted Publisher on npm yet: those land through the token, without provenance, and every other package keeps its provenance attestation. ## First-time publishes & `latest` -npm requires every package to always have a `latest` dist-tag, so the **first** version ever published becomes `latest` β€” even when `pnpm publish --tag pr-` is used. There is no way around this: npm rejects any attempt to delete the `latest` tag (`npm dist-tag rm latest` returns `400 Bad Request`), and a package cannot exist without one. +npm requires every package to have a `latest` dist-tag at all times, so the **first** version ever published becomes `latest`, even with `pnpm publish --tag pr-`. There is no way around it: npm rejects any attempt to delete the `latest` tag (`npm dist-tag rm latest` returns `400 Bad Request`), and a package can't exist without one. -So for a brand-new package, the first PR preview unavoidably becomes the version that `pnpm add ` (no tag) resolves to, until a stable release re-points `latest`. +So for a new package, the first PR preview becomes the version that `pnpm add `, with no tag, resolves to, until a stable release moves `latest`. -The action does not try to fix this at the registry (it can't). Instead it detects first-time publishes and reports `firstTime: true`, and the PR comment marks those packages with `†` and a footnote. Each published preview stays installable by tag or exact version: +The action doesn't try to fix this at the registry, because it can't. It detects first publishes and reports `firstTime: true`, and the PR comment marks those packages with `†` and a footnote. Each published preview stays installable by tag or by exact version: -- `pnpm add @pr-` β€” by PR tag. -- `pnpm add @` β€” by exact `-git-` version. +- `pnpm add @pr-`, by the PR's tag. +- `pnpm add @`, by the exact `-git-` version. -The fix is to cut a real release: the normal release pipeline publishes a stable version and re-points `latest` to it. +The fix is a real release: the normal release pipeline publishes a stable version and moves `latest` to it. ## How publishing works -The action shells out to the real `pnpm` CLI for the heavy lifting: +The action runs the real `pnpm` CLI for the work: -- `pnpm list -r --json --depth 0` to enumerate the workspace. +- `pnpm list -r --json --depth 0` to list the workspace. - `pnpm version prerelease --preid git- --no-git-tag-version` to bump. - `pnpm publish --tag pr- --no-git-checks [--provenance]` to publish. -`pnpm publish` takes care of `workspace:*` and `catalog:` resolution, `publishConfig` overrides, lifecycle scripts (`prepublishOnly`, `prepack`, `prepare`), and the npm Trusted Publisher OIDC exchange. This action just orchestrates which packages get bumped and the auth-mode selection. - -The mutating commands (`pnpm version`, `pnpm publish`) stream their combined output to the Actions log inside collapsible groups, so a run's progress is visible. The JSON query commands (`pnpm list`, `pnpm view`) stay silent β€” their output is parsed, not displayed. +`pnpm publish` handles `workspace:*` and `catalog:` resolution, `publishConfig` overrides, lifecycle scripts (`prepublishOnly`, `prepack`, `prepare`) and the npm Trusted Publisher OIDC exchange. The action only picks which packages get bumped, and the auth mode. -## Requirements on the calling workflow - -In `server` and `publish` modes `pnpm publish` is invoked from inside the action, so the caller's job needs: - -- **`pnpm` available in `PATH`** β€” install node and pnpm with mise through [`jdx/mise-action`](https://github.com/jdx/mise-action), as in the [two-job example](#two-job-previews) (see [Migrating to mise](../setup-pnpm/README.md#migrating-to-mise)). -- **A populated `node_modules`** β€” `pnpm install` must run before the action so lifecycle scripts (`prepack` / `prepare` doing `pnpm build`, `tsdown`, etc.) can find their devDependencies. - -`comment` mode needs none of that: it runs in an empty job. +The commands that change things (`pnpm version`, `pnpm publish`) stream their combined output to the Actions log inside collapsible groups, so a run's progress shows. The JSON query commands (`pnpm list`, `pnpm view`) stay silent: their output is parsed, not shown. ## Security notes -- The action **refuses to run on `pull_request_target`** in every mode. That trigger gives the workflow access to base-branch secrets while letting it check out PR-controlled code, which combined with lifecycle scripts in `prepack`/`prepare` would let any PR exfiltrate the publish credentials. Use `on: pull_request` β€” GitHub strips secrets for fork PRs there. -- `comment` mode treats `packages` as untrusted (it comes from a job that ran PR code) and refuses to run unless: +- The action **refuses to run on `pull_request_target`**, in every mode. That trigger gives the workflow the base branch's secrets while it checks out PR code, and with lifecycle scripts in `prepack` or `prepare`, any PR could take the publish credentials. Use `on: pull_request`, where GitHub strips secrets for PRs from forks. +- `comment` mode treats `packages` as untrusted, because it comes from a job that ran PR code, and refuses to run unless: - the event is not `pull_request_target` or `workflow_run`; - - `GITHUB_WORKSPACE` is empty, i.e. nothing in the job checked out or wrote files (the reusable workflow has no checkout, and callers cannot add steps to it); - - `packages` is valid JSON of at most 64 KiB and matches a strict schema: an array of at most 100 objects with exactly `packageName` (lowercase npm name, optionally scoped, at most 214 characters), `nextVersion` (semver) and an optional boolean `firstTime`. Anything else is rejected, and the error names only the failing fields, never the input itself. -- The PR number and head SHA come from the event payload, not from the previous job. Package names and versions are rendered inside code spans, so they can't turn into links, mentions or markup. -- When updating, only marker comments written by the app's own bot account (`[bot]`, a `Bot` user) are considered. A human or another bot (changeset-bot, github-actions…) that quotes the marker is never picked: the app token could not edit that comment, and the job would fail with a 403 on every run. -- All runtime tokens (`github_token`, the npm auth token transiently written to `~/.npmrc`, and the `vland-bot` OIDC token) are registered with `core.setSecret` so accidental log echoes are masked by the runner. Tokens are never logged. + - `GITHUB_WORKSPACE` is empty, so nothing in the job checked out or wrote files (the reusable workflow has no checkout, and callers can't add steps to it); + - `packages` is valid JSON of at most 64 KiB and matches a strict schema: an array of at most 100 objects with exactly `packageName` (a lowercase npm name, scoped or not, of at most 214 characters), `nextVersion` (semver) and an optional boolean `firstTime`. Anything else is rejected, and the error names only the fields that failed, never the input itself. +- The PR number and the head SHA come from the event payload, not from the previous job. Package names and versions are rendered inside code spans, so they can't turn into links, mentions or markup. +- When updating, only marker comments written by the app's own bot account (`[bot]`, a `Bot` user) count. A person or another bot (changeset-bot, github-actions and others) that quotes the marker is never picked: the app token couldn't edit that comment, and the job would fail with a 403 on every run. +- Every runtime token (`github_token`, the npm auth token written to `~/.npmrc` for the publish, and the `vland-bot` OIDC token) is registered with `core.setSecret`, so the runner masks it if a log echoes it by accident. Tokens are never logged. diff --git a/actions/preview-release/README.md b/actions/preview-release/README.md index 663e858..42cb1de 100644 --- a/actions/preview-release/README.md +++ b/actions/preview-release/README.md @@ -1,35 +1,38 @@ -# Preview release +# preview-release -GitHub Action that publishes a **per-PR preview release** of a standalone library (a single npm package, not part of a workspace) and gets a sticky comment posted on the pull request with the published version. +This action publishes a **per-PR preview release** of a standalone library, a single npm package outside a workspace, and gets a sticky comment with the published version posted on the pull request. -For each PR: - -- The package in `working_directory` (default the repo root) gets its version bumped to `-git-` (prerelease). -- The package is published to npm under the dist-tag `pr-`. -- Private packages (`"private": true`) are skipped. -- A package's **first ever publish** is a special case: npm force-assigns the `latest` dist-tag to it (and won't let that tag be removed), so the preview becomes the default `pnpm add ` target until a stable release re-points `latest`. The action flags these first-time publishes so the PR comment can warn about it. See [First-time publishes & `latest`](#first-time-publishes--latest). -- The PR comment is posted either by the `vland-bot` server (`mode: server`, the default) or by a second job that never runs PR code (`mode: publish` + `mode: comment`). See [Modes](#modes). - -> Working in a pnpm monorepo? Use [`monorepo-preview-release`](../monorepo-preview-release/README.md) instead β€” it detects changed packages and their workspace dependents. +> For a pnpm monorepo, use [`monorepo-preview-release`](../monorepo-preview-release/README.md) instead: it detects the changed packages and their workspace dependents. ## Modes -The `mode` input picks what a run does: +The PR comment is posted either by the `vland-bot` server (`mode: server`, the default) or by a second job that never runs PR code (`mode: publish` and `mode: comment`). The `mode` input picks what a run does: | `mode` | Publishes | Comments | Use it for | | --- | --- | --- | --- | -| `server` (default) | yes | via the `vland-bot` server: the action POSTs the result, authenticated with a GitHub OIDC token (audience `vland-bot`) | variableland repos. This is the behaviour from before modes existed. | -| `publish` | yes | no. Sets the [`packages`, `pr-number` and `head-sha` outputs](#outputs) instead | Job 1 of the [two-job pattern](#two-job-previews) | -| `comment` | no | yes, directly: upserts the comment with `github_token` | Job 2 of the two-job pattern, through the [`preview-comment` reusable workflow](../../.github/workflows/preview-comment.yml) | +| `server` (default) | yes | Through the `vland-bot` server: the action POSTs the result, authenticated with a GitHub OIDC token (audience `vland-bot`). | Repos that let the `vland-bot` server post the comment. It is the behavior from before modes existed. | +| `publish` | yes | No. It sets the [`packages`, `pr-number` and `head-sha` outputs](#outputs) instead. | Job 1 of the [two-job pattern](#two-job-previews). | +| `comment` | no | Yes, directly: it upserts the comment with `github_token`. | Job 2 of the two-job pattern, through the [`preview-comment` reusable workflow](../../.github/workflows/preview-comment.yml). | + +## Usage + +`mode: server`, the default, needs only the step: + +```yml +- name: πŸš€ Preview release + uses: withvariablelab/gh-actions/actions/preview-release@main +``` + +For `publish` and `comment`, see [Two-job previews](#two-job-previews). -## Two-job previews +### Two-job previews -The `vland-bot` server exists so the bot's private key is never in a job that runs PR code (the preview job installs and builds the PR). The two-job pattern keeps that property without the server: +The `vland-bot` server exists so the bot's private key is never in a job that runs PR code: the preview job installs and builds the PR. The two-job pattern keeps that property without the server: -1. **`preview`** runs the PR code and publishes with npm OIDC (`mode: publish`). It has `id-token: write` and `contents: read`, no app key, and only hands the published package list to the next job as an output. -2. **`preview-comment`** (`needs: preview`) calls the reusable workflow [`preview-comment.yml`](../../.github/workflows/preview-comment.yml). That workflow never checks out or runs PR code: it mints a `vland-bot` installation token (current repo only, `pull-requests: write`) and runs this action with `mode: comment`, which validates the package list and upserts the comment. +1. **`preview`** runs the PR code and publishes with npm OIDC (`mode: publish`). It has `id-token: write` and `contents: read`, no app key, and only hands the published package list to the next job, as an output. +2. **`preview-comment`** (`needs: preview`) calls the reusable workflow [`preview-comment.yml`](../../.github/workflows/preview-comment.yml). That workflow never checks out or runs PR code: it mints a `vland-bot` installation token, for the current repo only and with `pull-requests: write`, and runs this action with `mode: comment`, which validates the package list and upserts the comment. -The comment has the same layout and the same `` marker as the one the server posts, and it is posted by the same `vland-bot` app, so job 2 updates comments the server created earlier. +The comment has the same layout and the same `` marker as the one the server posts, and the same `vland-bot` app posts it, so job 2 updates comments the server created earlier. ```yml name: CI @@ -90,9 +93,9 @@ jobs: What the caller needs: -- The org (or repo) **variable** `VLAND_BOT_CLIENT_ID` and **secret** `VLAND_BOT_PRIVATE_KEY`. The reusable workflow reads `vars.VLAND_BOT_CLIENT_ID` itself: in a called workflow, `vars` resolves against the caller's repository. The secret is passed explicitly (no `secrets: inherit`). +- The org or repo **variable** `VLAND_BOT_CLIENT_ID` and **secret** `VLAND_BOT_PRIVATE_KEY`. The reusable workflow reads `vars.VLAND_BOT_CLIENT_ID` itself: in a called workflow, `vars` resolves against the caller's repository. The secret is passed explicitly, with no `secrets: inherit`. - `vland-bot` installed on the repository. -- `packages` travels **only** through `outputs:` and `with:`. Never interpolate `needs.preview.outputs.packages` (or any other output of the preview job) into a `run:` script: that job ran PR code, so its outputs are untrusted and would be a script injection. +- `packages` travels **only** through `outputs:` and `with:`. Never interpolate `needs.preview.outputs.packages`, or any other output of the preview job, into a `run:` script: that job ran PR code, so its outputs are untrusted and would be a script injection. `preview-comment.yml` inputs: @@ -103,51 +106,47 @@ What the caller needs: If `preview` fails or is skipped, `needs` skips `preview-comment` too. -## Usage - -`mode: server` (default), as used by variableland repos: - -```yml -- name: πŸš€ Preview release - uses: withvariablelab/gh-actions/actions/preview-release@main -``` - -For `publish` + `comment`, see [Two-job previews](#two-job-previews). - ## Inputs | Input | Required | Default | Description | | --- | --- | --- | --- | | `mode` | no | `server` | `server`, `publish` or `comment`. See [Modes](#modes). | -| `npm_token` | no | β€” | `server`/`publish` only. npm classic/automation token. When set, enables the OIDC + token fallback mode (see below). | -| `publish_mode` | no | auto-detected | `server`/`publish` only. Override the [authentication mode](#authentication-modes): `token-only`, `oidc-only` or `oidc-with-token-fallback`. | -| `working_directory` | no | `.` | `server`/`publish` only. Directory containing the `package.json` to publish. Use this when the library lives in a subdirectory. | -| `vland_bot_url` | no | `https://bot.variable.land` | `server` only. Base URL of the `vland-bot` server that receives the post-publish notification. | -| `packages` | in `comment` | β€” | `comment` only. The `packages` output of a `publish` run (JSON). | -| `app_slug` | in `comment` | β€” | `comment` only. Slug of the GitHub App behind `github_token` (`preview-comment.yml` passes the `app-slug` output of `actions/create-github-app-token`). Only a marker comment written by `[bot]` is updated. | -| `github_token` | in `comment` | β€” | `comment` only. Token that can write PR comments (the `vland-bot` token minted by `preview-comment.yml`). | +| `npm_token` | no | β€” | `server` and `publish` only. An npm classic or automation token. When set, it turns on the OIDC and token fallback mode (see [Authentication modes](#authentication-modes)). | +| `publish_mode` | no | detected | `server` and `publish` only. Overrides the [authentication mode](#authentication-modes): `token-only`, `oidc-only` or `oidc-with-token-fallback`. | +| `working_directory` | no | `.` | `server` and `publish` only. The directory with the `package.json` to publish, for a library that lives in a subdirectory. | +| `vland_bot_url` | no | `https://bot.variable.land` | `server` only. The base URL of the `vland-bot` server that gets the notification after the publish. | +| `packages` | in `comment` | β€” | `comment` only. The `packages` output of a `publish` run, as JSON. | +| `app_slug` | in `comment` | β€” | `comment` only. The slug of the GitHub App behind `github_token` (`preview-comment.yml` passes the `app-slug` output of `actions/create-github-app-token`). Only a marker comment written by `[bot]` is updated. | +| `github_token` | in `comment` | β€” | `comment` only. A token that can write PR comments: the `vland-bot` token that `preview-comment.yml` mints. | ## Outputs Set in `mode: publish` only: -| Output | Description | -| --- | --- | -| `packages` | JSON array with the published package (`[]` when the package is private). Fields below. | -| `pr-number` | Number of the pull request. | -| `head-sha` | Head commit SHA of the pull request. | +- `packages`: a JSON array with the published package, or `[]` when the package is private. Its fields are below. +- `pr-number`: the number of the pull request. +- `head-sha`: the head commit SHA of the pull request. -Each `packages` entry (the same payload `mode: server` POSTs to `vland-bot` at `POST /v1/github/preview-release`): +Each `packages` entry is the same payload that `mode: server` POSTs to `vland-bot` at `POST /v1/github/preview-release`: | Field | Type | Description | | --- | --- | --- | | `packageName` | `string` | The npm package name. | | `nextVersion` | `string` | The published preview version (`-git-`). | -| `firstTime` | `boolean` | `true` when this run published the package's very first version. npm makes that version `latest` (unavoidably β€” see below), so the comment warns that `pnpm add ` currently resolves to a preview build until a stable release re-points `latest`. | +| `firstTime` | `boolean` | `true` when this run published the package's first version ever. npm makes that version `latest`, and nothing can prevent it (see [First-time publishes & `latest`](#first-time-publishes--latest)), so the comment warns that `pnpm add ` resolves to a preview build until a stable release moves `latest`. | + +## Requirements + +In `server` and `publish` modes, the action runs `pnpm publish` itself, so the caller's job needs: + +- **`pnpm` on the `PATH`.** Install node and pnpm with mise through [`jdx/mise-action`](https://github.com/jdx/mise-action), as in the [two-job example](#two-job-previews). See [Migrating to mise](../setup-pnpm/README.md#migrating-to-mise). +- **A populated `node_modules`.** `pnpm install` has to run before the action, so lifecycle scripts (`prepack` or `prepare` running `pnpm build`, `tsdown` and the like) find their devDependencies. + +`comment` mode needs none of that: it runs in an empty job. ## Required permissions -`server` and `publish` (the job that runs the PR code): +`server` and `publish`, in the job that runs the PR code: ```yml permissions: @@ -157,58 +156,54 @@ permissions: `comment`: none from `GITHUB_TOKEN` (`permissions: {}`). It uses the app token passed as `github_token`, which needs `pull-requests: write` on the current repository. +## What a run publishes + +- The package in `working_directory`, the repository root by default, gets its version bumped to the prerelease `-git-`. +- The package is published to npm under the dist-tag `pr-`. +- A private package (`"private": true`) is skipped. +- A package's **first publish ever** is a special case. npm gives it the `latest` dist-tag and won't let that tag be removed, so the preview becomes the default `pnpm add ` target until a stable release moves `latest`. The action flags these first publishes, so the PR comment can warn about it. See [First-time publishes & `latest`](#first-time-publishes--latest). + ## Authentication modes -`server` and `publish` pick one of three npm publishing modes automatically based on the directory state and the inputs (override with `publish_mode`): +`server` and `publish` pick one of three npm publishing modes from the directory's state and the inputs. `publish_mode` overrides the choice. -| Detected state | Mode | Behavior | -| --- | --- | --- | -| `.npmrc` already provides auth | `token-only` | Publishes using the existing `.npmrc` credentials. Never adds `--provenance`. | -| `npm_token` input is provided (and no `.npmrc` auth) | `oidc-with-token-fallback` | Publishes with OIDC + provenance via `pnpm publish --provenance`. If the publish fails **and** the package is unpublished on npm, it falls back to `NPM_TOKEN` auth without provenance. | -| Neither of the above | `oidc-only` | Publishes with OIDC + provenance. Any failure is fatal. | +- **`token-only`**, when `.npmrc` already provides auth: publishes with the existing `.npmrc` credentials, and never adds `--provenance`. +- **`oidc-with-token-fallback`**, when the `npm_token` input is set and `.npmrc` has no auth: publishes with OIDC and provenance through `pnpm publish --provenance`. If the publish fails **and** the package has never been published to npm, it falls back to `NPM_TOKEN` auth without provenance. +- **`oidc-only`**, in every other case: publishes with OIDC and provenance. Any failure is fatal. -The `oidc-with-token-fallback` mode exists to handle the **first-time publish** of a package that doesn't yet have a Trusted Publisher configured on npm: it lands via the token (no provenance), and every subsequent publish keeps its provenance attestation. +The `oidc-with-token-fallback` mode exists for the **first publish** of a package that has no Trusted Publisher on npm yet: it lands through the token, without provenance, and every later publish keeps its provenance attestation. ## First-time publishes & `latest` -npm requires every package to always have a `latest` dist-tag, so the **first** version ever published becomes `latest` β€” even when `pnpm publish --tag pr-` is used. There is no way around this: npm rejects any attempt to delete the `latest` tag (`npm dist-tag rm latest` returns `400 Bad Request`), and a package cannot exist without one. +npm requires every package to have a `latest` dist-tag at all times, so the **first** version ever published becomes `latest`, even with `pnpm publish --tag pr-`. There is no way around it: npm rejects any attempt to delete the `latest` tag (`npm dist-tag rm latest` returns `400 Bad Request`), and a package can't exist without one. -So for a brand-new package, the first PR preview unavoidably becomes the version that `pnpm add ` (no tag) resolves to, until a stable release re-points `latest`. +So for a new package, the first PR preview becomes the version that `pnpm add `, with no tag, resolves to, until a stable release moves `latest`. -The action does not try to fix this at the registry (it can't). Instead it detects the first-time publish and reports `firstTime: true`, and the PR comment marks the package with `†` and a footnote. The published preview stays installable by tag or exact version: +The action doesn't try to fix this at the registry, because it can't. It detects the first publish and reports `firstTime: true`, and the PR comment marks the package with `†` and a footnote. The published preview stays installable by tag or by exact version: -- `pnpm add @pr-` β€” by PR tag. -- `pnpm add @` β€” by exact `-git-` version. +- `pnpm add @pr-`, by the PR's tag. +- `pnpm add @`, by the exact `-git-` version. -The fix is to cut a real release: the normal release pipeline publishes a stable version and re-points `latest` to it. +The fix is a real release: the normal release pipeline publishes a stable version and moves `latest` to it. ## How publishing works -The action shells out to the real `pnpm` CLI for the heavy lifting: +The action runs the real `pnpm` CLI for the work: - `pnpm version prerelease --preid git- --no-git-tag-version` to bump. - `pnpm publish --tag pr- --no-git-checks [--provenance]` to publish. -`pnpm publish` takes care of `publishConfig` overrides, lifecycle scripts (`prepublishOnly`, `prepack`, `prepare`), and the npm Trusted Publisher OIDC exchange. This action just orchestrates the bump and the auth-mode selection. - -The mutating commands (`pnpm version`, `pnpm publish`) stream their combined output to the Actions log inside collapsible groups, so a run's progress is visible. The JSON query command (`pnpm view`) stays silent β€” its output is parsed, not displayed. +`pnpm publish` handles `publishConfig` overrides, lifecycle scripts (`prepublishOnly`, `prepack`, `prepare`) and the npm Trusted Publisher OIDC exchange. The action only handles the bump and picks the auth mode. -## Requirements on the calling workflow - -In `server` and `publish` modes `pnpm publish` is invoked from inside the action, so the caller's job needs: - -- **`pnpm` available in `PATH`** β€” install node and pnpm with mise through [`jdx/mise-action`](https://github.com/jdx/mise-action), as in the [two-job example](#two-job-previews) (see [Migrating to mise](../setup-pnpm/README.md#migrating-to-mise)). -- **A populated `node_modules`** β€” `pnpm install` must run before the action so lifecycle scripts (`prepack` / `prepare` doing `pnpm build`, `tsdown`, etc.) can find their devDependencies. - -`comment` mode needs none of that: it runs in an empty job. +The commands that change things (`pnpm version`, `pnpm publish`) stream their combined output to the Actions log inside collapsible groups, so a run's progress shows. The JSON query command (`pnpm view`) stays silent: its output is parsed, not shown. ## Security notes -- The action **refuses to run on `pull_request_target`** in every mode. That trigger gives the workflow access to base-branch secrets while letting it check out PR-controlled code, which combined with lifecycle scripts in `prepack`/`prepare` would let any PR exfiltrate the publish credentials. Use `on: pull_request` β€” GitHub strips secrets for fork PRs there. -- `comment` mode treats `packages` as untrusted (it comes from a job that ran PR code) and refuses to run unless: +- The action **refuses to run on `pull_request_target`**, in every mode. That trigger gives the workflow the base branch's secrets while it checks out PR code, and with lifecycle scripts in `prepack` or `prepare`, any PR could take the publish credentials. Use `on: pull_request`, where GitHub strips secrets for PRs from forks. +- `comment` mode treats `packages` as untrusted, because it comes from a job that ran PR code, and refuses to run unless: - the event is not `pull_request_target` or `workflow_run`; - - `GITHUB_WORKSPACE` is empty, i.e. nothing in the job checked out or wrote files (the reusable workflow has no checkout, and callers cannot add steps to it); - - `packages` is valid JSON of at most 64 KiB and matches a strict schema: an array of at most 100 objects with exactly `packageName` (lowercase npm name, optionally scoped, at most 214 characters), `nextVersion` (semver) and an optional boolean `firstTime`. Anything else is rejected, and the error names only the failing fields, never the input itself. -- The PR number and head SHA come from the event payload, not from the previous job. Package names and versions are rendered inside code spans, so they can't turn into links, mentions or markup. -- When updating, only marker comments written by the app's own bot account (`[bot]`, a `Bot` user) are considered. A human or another bot (changeset-bot, github-actions…) that quotes the marker is never picked: the app token could not edit that comment, and the job would fail with a 403 on every run. -- All runtime tokens (`github_token`, the npm auth token transiently written to `~/.npmrc`, and the `vland-bot` OIDC token) are registered with `core.setSecret` so accidental log echoes are masked by the runner. Tokens are never logged. + - `GITHUB_WORKSPACE` is empty, so nothing in the job checked out or wrote files (the reusable workflow has no checkout, and callers can't add steps to it); + - `packages` is valid JSON of at most 64 KiB and matches a strict schema: an array of at most 100 objects with exactly `packageName` (a lowercase npm name, scoped or not, of at most 214 characters), `nextVersion` (semver) and an optional boolean `firstTime`. Anything else is rejected, and the error names only the fields that failed, never the input itself. +- The PR number and the head SHA come from the event payload, not from the previous job. Package names and versions are rendered inside code spans, so they can't turn into links, mentions or markup. +- When updating, only marker comments written by the app's own bot account (`[bot]`, a `Bot` user) count. A person or another bot (changeset-bot, github-actions and others) that quotes the marker is never picked: the app token couldn't edit that comment, and the job would fail with a 403 on every run. +- Every runtime token (`github_token`, the npm auth token written to `~/.npmrc` for the publish, and the `vland-bot` OIDC token) is registered with `core.setSecret`, so the runner masks it if a log echoes it by accident. Tokens are never logged. diff --git a/actions/railway-redeploy/README.md b/actions/railway-redeploy/README.md index 45e68e4..501ef93 100644 --- a/actions/railway-redeploy/README.md +++ b/actions/railway-redeploy/README.md @@ -1,8 +1,6 @@ -# Railway redeploy +# railway-redeploy -GitHub Action that triggers a redeploy of a Railway service via the Railway GraphQL API. - -The action looks up the target service from `project_id` + `environment` + `service_name`, finds the most recent deployment in `SUCCESS`, `SLEEPING`, or `FAILED` state that is marked `canRedeploy`, and calls the `deploymentRedeploy` mutation. It returns the Railway panel URL of the redeployed service as an output, and (because failed deployments are eligible) it can be used to retry a broken deploy without having to push a new commit. +This action redeploys a Railway service through the Railway GraphQL API, and returns the service's URL in the Railway panel. ## Usage @@ -20,27 +18,27 @@ The action looks up the target service from `project_id` + `environment` + `serv | Input | Required | Default | Description | | --- | --- | --- | --- | -| `project_id` | yes | β€” | Railway project ID where the service lives. | -| `environment` | yes | β€” | Environment name (case-insensitive), e.g. `staging`, `production`. | -| `service_name` | yes | β€” | Service name as shown in the Railway dashboard (case-insensitive). No service ID is needed. | -| `api_token` | yes | β€” | Railway account or workspace token with access to the project. Project tokens don't work: the action sends `Authorization: Bearer`, and Railway expects project tokens in a `Project-Access-Token` header. | -| `api_url` | no | `https://backboard.railway.com/graphql/v2` | Override only if you target a non-standard Railway endpoint. | +| `project_id` | yes | β€” | The ID of the Railway project the service is in. | +| `environment` | yes | β€” | The environment's name, such as `staging` or `production`. Case doesn't matter. | +| `service_name` | yes | β€” | The service's name as the Railway dashboard shows it. Case doesn't matter, and no service ID is needed. | +| `api_token` | yes | β€” | A Railway account or workspace token with access to the project. Project tokens don't work: the action sends `Authorization: Bearer`, and Railway expects a project token in a `Project-Access-Token` header. | +| `api_url` | no | `https://backboard.railway.com/graphql/v2` | Change it only to target a non-standard Railway endpoint. | ## Outputs | Output | Description | | --- | --- | -| `service_url` | URL of the redeployed service in the Railway panel (`https://railway.com/project//service/?environmentId=`). | - -## Required permissions - -None from `GITHUB_TOKEN`: the action only talks to the Railway API, with `api_token`. Grant the job whatever its other steps need, e.g. `contents: read` for `actions/checkout`, or `permissions: {}` if it has none. +| `service_url` | The URL of the redeployed service in the Railway panel (`https://railway.com/project//service/?environmentId=`). | ## Requirements - A Railway token in a secret, passed as `api_token` (see [Inputs](#inputs)). - Nothing else. The action runs on the runner's Node 24 (`runs.using: node24`) from the committed, bundled `dist/index.js`. It needs no Bun, no Node or pnpm setup step, no dependency install, and no checkout: it never reads the repository. +## Required permissions + +None from `GITHUB_TOKEN`: the action only talks to the Railway API, with `api_token`. Grant the job what its other steps need, such as `contents: read` for `actions/checkout`, or `permissions: {}` if they need nothing. + ## Example ```yml @@ -86,3 +84,7 @@ jobs: SERVICE_URL: ${{ steps.redeploy.outputs.service_url }} run: echo "Redeployed β†’ $SERVICE_URL" ``` + +## How it works + +The action finds the service from `project_id`, `environment` and `service_name`. It takes the most recent deployment in the `SUCCESS`, `SLEEPING` or `FAILED` state that is marked `canRedeploy`, and calls the `deploymentRedeploy` mutation on it. Because a failed deployment counts, the action can retry a broken deploy without a new commit. diff --git a/actions/setup-bun/README.md b/actions/setup-bun/README.md index f83ffc5..6e28647 100644 --- a/actions/setup-bun/README.md +++ b/actions/setup-bun/README.md @@ -1,10 +1,6 @@ -# Setup Bun +# setup-bun -GitHub Action that installs [Bun](https://bun.sh) pinned to the version declared in `.bun-version`, then logs the resolved version. - -## Requirements - -- A `.bun-version` file at the repository root containing the Bun version to install. The action fails fast if the file is missing. +This action installs [Bun](https://bun.sh) at the version in `.bun-version`, then logs the version it installed. ## Usage @@ -12,3 +8,7 @@ GitHub Action that installs [Bun](https://bun.sh) pinned to the version declared - name: βŒ› Setup Bun uses: withvariablelab/gh-actions/actions/setup-bun@main ``` + +## Requirements + +- A `.bun-version` file at the repository root, with the Bun version to install. The action fails when the file is missing. diff --git a/actions/setup-pnpm-bun/README.md b/actions/setup-pnpm-bun/README.md index dfd170b..113b1ff 100644 --- a/actions/setup-pnpm-bun/README.md +++ b/actions/setup-pnpm-bun/README.md @@ -1,22 +1,22 @@ -# Setup pnpm + Bun +# setup-pnpm-bun -> [!WARNING] -> **Deprecated.** Install node, pnpm and bun with [mise](https://mise.jdx.dev) through [`jdx/mise-action`](https://github.com/jdx/mise-action) instead. It stays in place for the workflows that still use it. Follow [Migrating to mise](../setup-pnpm/README.md#migrating-to-mise) in `setup-pnpm`, with `bun` added to `[tools]` in `mise.toml` (the version from `.bun-version`). - -## Deprecated behavior - -The action combines [`setup-pnpm`](../setup-pnpm/README.md) and [`setup-bun`](../setup-bun/README.md) in a single step, for workflows that need both runtimes (e.g. pnpm for the workspace, Bun for scripts or tests). - -It runs the full pnpm flow (install pnpm, upgrade npm, cache the store, `pnpm install --frozen-lockfile --prefer-offline`) and then installs Bun from `.bun-version`. Versions of `pnpm`, `npm`, and `bun` are logged at the end. - -### Requirements +This action does what [`setup-pnpm`](../setup-pnpm/README.md) and [`setup-bun`](../setup-bun/README.md) do in one step, for workflows that need both runtimes, such as pnpm for the workspace and Bun for scripts or tests. -- `pnpm-lock.yaml` at the repository root and a `packageManager` field in `package.json`. -- `.bun-version` at the repository root containing the Bun version to install. The action fails fast if it's missing. +> [!WARNING] +> **Deprecated.** Install node, pnpm and bun with [mise](https://mise.jdx.dev) through [`jdx/mise-action`](https://github.com/jdx/mise-action) instead. The action stays in place for the workflows that still use it. Follow [Migrating to mise](../setup-pnpm/README.md#migrating-to-mise) in `setup-pnpm`, with `bun` added to `[tools]` in `mise.toml` at the version in `.bun-version`. -### Usage +## Usage ```yml - name: βŒ› Setup pnpm + Bun uses: withvariablelab/gh-actions/actions/setup-pnpm-bun@main ``` + +## Requirements + +- A `pnpm-lock.yaml` at the repository root, and a `packageManager` field in `package.json`. +- A `.bun-version` file at the repository root, with the Bun version to install. The action fails when the file is missing. + +## What it does + +It runs the whole pnpm flow: it installs pnpm, upgrades npm, caches the store and runs `pnpm install --frozen-lockfile --prefer-offline`. Then it installs Bun from `.bun-version`. At the end it logs the `pnpm`, `npm` and `bun` versions. diff --git a/actions/setup-pnpm/README.md b/actions/setup-pnpm/README.md index 9b36574..d0c849c 100644 --- a/actions/setup-pnpm/README.md +++ b/actions/setup-pnpm/README.md @@ -1,7 +1,29 @@ -# Setup pnpm +# setup-pnpm + +This action installs pnpm, upgrades npm, caches the pnpm store and installs the dependencies. > [!WARNING] -> **Deprecated.** Install node and pnpm with [mise](https://mise.jdx.dev) through [`jdx/mise-action`](https://github.com/jdx/mise-action) instead. It stays in place for the workflows that still use it. See [Migrating to mise](#migrating-to-mise). +> **Deprecated.** Install node and pnpm with [mise](https://mise.jdx.dev) through [`jdx/mise-action`](https://github.com/jdx/mise-action) instead. The action stays in place for the workflows that still use it. See [Migrating to mise](#migrating-to-mise). + +## Usage + +```yml +- name: βŒ› Setup pnpm + uses: withvariablelab/gh-actions/actions/setup-pnpm@main +``` + +## Requirements + +- A `pnpm-lock.yaml` at the repository root, or wherever your job runs `pnpm install` from. +- A `packageManager` field in `package.json`, which `pnpm/action-setup` reads to pick the pnpm version. + +## What it does + +- Installs [pnpm](https://pnpm.io) with `pnpm/action-setup`. +- Upgrades `npm` to an exact 11.x, bumped by hand (now `11.21.0`), because npm trusted publishing needs it. +- Caches the pnpm store, keyed by the month and the hash of `pnpm-lock.yaml`. +- Runs `pnpm install --frozen-lockfile --prefer-offline`. +- Logs the `pnpm` and `npm` versions it ends up with. ## Migrating to mise @@ -19,7 +41,7 @@ lockfile = true ``` -2. Generate `mise.lock` for every platform CI runs on (plus the ones your team develops on) and commit it: +2. Generate `mise.lock` for every platform CI runs on, plus the ones your team develops on, and commit it: ```sh mise lock -p linux-x64,linux-x64-musl,linux-arm64,linux-arm64-musl,macos-x64,macos-arm64,windows-x64 @@ -40,32 +62,10 @@ Two behaviors of `jdx/mise-action` v4.3.0 matter here: -- When it finds a `mise.lock` (walking up from `working_directory`), it runs `mise install --locked`. That fails for any tool of any config the job loads that has no lockfile entry for the runner's platform, so every config needs a `mise.lock` that covers it (`linux-x64` on `ubuntu-latest`). -- It exports `[env] _.path` from `mise.toml` to later steps (input `export_path`, default `true`), so a `node_modules/.bin` entry there puts local binaries on `PATH`. +- When it finds a `mise.lock`, walking up from `working_directory`, it runs `mise install --locked`. That fails for any tool of any config the job loads that has no lockfile entry for the runner's platform, so every config needs a `mise.lock` that covers it (`linux-x64` on `ubuntu-latest`). +- It exports `[env] _.path` from `mise.toml` to later steps (input `export_path`, `true` by default), so a `node_modules/.bin` entry there puts local binaries on the `PATH`. What this action did that the mise steps don't: -- **npm upgrade:** not needed. npm trusted publishing (OIDC) needs npm >= 11.5.1, and the npm bundled with the locked Node already has it (Node 24.21.0 ships npm 11.19.0). -- **pnpm store cache:** dropped. `cache: true` caches the tools mise installs, not the pnpm store. - -## Deprecated behavior - -The action prepares a pnpm-based workflow: - -- Installs [pnpm](https://pnpm.io) via `pnpm/action-setup` -- Upgrades `npm` to an exact, hand-bumped 11.x (currently `11.21.0`, required for npm trusted publishing) -- Caches the pnpm store (keyed by month + `pnpm-lock.yaml` hash) -- Runs `pnpm install --frozen-lockfile --prefer-offline` -- Logs the resolved `pnpm` and `npm` versions - -### Usage - -```yml -- name: βŒ› Setup pnpm - uses: withvariablelab/gh-actions/actions/setup-pnpm@main -``` - -### Requirements - -- A `pnpm-lock.yaml` at the repository root (or wherever your job runs `pnpm install` from). -- A `packageManager` field in `package.json` (read by `pnpm/action-setup` to pick the pnpm version). +- **The npm upgrade:** not needed. npm trusted publishing (OIDC) needs npm 11.5.1 or later, and the npm bundled with the locked Node already has it (Node 24.21.0 ships npm 11.19.0). +- **The pnpm store cache:** dropped. `cache: true` caches the tools mise installs, not the pnpm store. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..b6e17fb --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,19 @@ +# Contributing + +gh-actions takes bug reports and fixes for its actions and its reusable workflow. To set up the repo and run its checks, see the [development guide](development.md). + +## Issues + +- A bug report names the action, the ref it runs at (`@main` or a SHA) and the inputs it gets, and links the failed run or pastes its log. +- A new action, or a change to an action's inputs, outputs or modes, starts as an issue before any pull request. + +## Pull requests + +1. Branch from `main` with a prefix: `feat/`, `fix/`, `docs/`, `ci/`, `chore/` or `test/`. +2. Write commits as [Conventional Commits](https://www.conventionalcommits.org), with the action as the scope when the change is in one, such as `docs(railway-redeploy): drop the Bun requirement`. The pull request title takes the same form, because it becomes the squash commit. +3. A change to an action's `src/` commits the rebuilt `dist/` with it, because callers run `dist/`. +4. Run `mise run check`, `mise run test` and `mise run check-dist` before you push. Once installed, the git hooks run them too. + +CI has to pass, and a maintainer squash-merges. + +A merge to `main` is the release. Callers that use `@main` run the new code on their next run, and callers that pin a SHA keep theirs. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..68cbaf2 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,72 @@ +# Development + +How to work on gh-actions in a clone. The [contributing guide](contributing.md) covers how a change gets in. + +## Layout + +- `actions//`: one action per folder, named as in `uses:`, with its README. A Node action also holds `src/`, the committed `dist/`, and its own `package.json`, `package-lock.json`, `mise.toml`, `tsconfig.json` and `biome.json`. A composite action holds only `action.yml` and its README. +- `.github/workflows/`: the repository's own CI, and [`preview-comment.yml`](../.github/workflows/preview-comment.yml), the reusable workflow that callers run. +- The root: the tooling the actions share (Biome, TypeScript, run-run and lefthook), installed with pnpm, and the mise tasks that run in every action. + +## Setup + +[mise](https://mise.jdx.dev) pins Node 24 and pnpm 10.30.3 in `mise.toml`, which needs mise 2026.6.6 or later. The repository has no `mise.lock`. `mise.toml` makes the root a mise monorepo with each folder in `actions/` as a config root, so a task run at the root runs in every action that declares it. It also puts `node_modules/.bin` on the `PATH`, so the tools run by name. + +```sh +mise run setup # mise install, pnpm install at the root, then npm install in each Node action +pnpm lefthook install # the git hooks, once per clone +``` + +pnpm doesn't run lefthook's install script (`ignoredBuiltDependencies` in `package.json`), so the hooks wait for `lefthook install`. + +## Tasks + +```sh +mise run check # Biome, then the type check, in every Node action +mise run tsc # only the type check +mise run test # the unit tests, in every action that has them +mise run build # build dist/ in every Node action, skipped when nothing changed +mise run check-dist # rebuild every dist/, and fail if it differs from the commit +``` + +In an action's folder, `mise run ` runs that action's task only. + +## CI + +- **On each pull request:** Check actions dist sync diffs the comment code that `preview-release` and `monorepo-preview-release` share, then runs `mise run test` and `mise run check-dist`. Workflow audit runs zizmor on the workflows, and runs again on each push to `main` (see [Security linting](#security-linting)). Both are required checks. +- **Git hooks** ([lefthook](https://lefthook.dev)): on commit, `mise run check` when a TypeScript file or a `tsconfig.json` under `actions/` is staged. On push, `mise run test` when an action's `src/` or `package.json` changed, and `mise run check-dist` when its `src/`, `dist/`, `package.json`, `package-lock.json` or `tsconfig.json` changed. +- **Every Monday:** Dependency audit runs `pnpm audit` on the root lockfile and `npm audit` in each Node action, and keeps an issue labeled `dependency-audit` open while it finds known vulnerabilities. + +## Node action convention + +Every Node action in this repository has the same shape: + +1. **`action.yml`** declares `runs.using: node24` and `main: dist/index.js`. +2. **The source** is in `src/`. The bundle, `dist/index.js`, is committed to the repository and runs as is on the GitHub Actions runner: no Docker build, and no install at run time. +3. **`mise.toml`** declares a `build` task that runs `ncc build src/index.ts -o dist`. mise tracks its `sources` and `outputs` and skips the build when nothing changed, so `mise run build` does **not** catch a hand-edited `dist/`. The root **`check-dist`** task does: it rebuilds every `dist/` with `--force`, and fails if anything under `dist/` differs from the commit, new files included. +4. The **lefthook `pre-push` hook** runs `mise run check-dist` for any push that touches an action's `src/`, `dist/`, `package.json`, `package-lock.json` or `tsconfig.json`, and blocks the push when `dist/` is out of sync with `src/`. +5. CI (Check actions dist sync) runs `check-dist` on every pull request. It is a required check, so a push that skips the local hook is still caught. +6. **Unit tests** sit next to the code as `src/**/*.test.ts`, and run with Node's built-in test runner (`node --test`, with native type stripping and no extra dependencies): `mise run test` in an action or at the root. The pre-push hook and Check actions dist sync run them too. +7. `preview-release` and `monorepo-preview-release` hold **byte-identical copies** of the comment-mode code (`src/comment.ts`, `src/types.ts`) and of the shared tests (`src/comment.test.ts`, `src/core.test.ts`). Change both copies together: CI diffs them. + +To add a Node action, copy the structure of `monorepo-preview-release` or `railway-redeploy`, change the inputs and the logic, and run `mise run build`. `mise run check-dist` works too: the pre-push hook runs it, and it tells you to commit the change to `dist/`. + +## Dependency cooldown + +A new dependency version has to be at least 24 hours old before it gets in. Most malicious releases are pulled within the first hour. + +- **Root tooling** (pnpm): [`pnpm-workspace.yaml`](../pnpm-workspace.yaml) sets `minimumReleaseAge: 1440`, except for `@variablelab/*` and `@rrlab/*`. pnpm 10.16 or later, pinned in `package.json` and `mise.toml`, refuses a version that is too new with `ERR_PNPM_NO_MATURE_MATCHING_VERSION`. +- **Action dependencies** (npm, one `package-lock.json` per action): npm has no lasting cooldown setting. When you add or update a dependency of an action, pass `--before` with a time 24 hours in the past, so npm resolves only versions published before it: + + ```sh + cd actions/ + npm install @ --before="$(node -p 'new Date(Date.now() - 864e5).toISOString()')" + ``` + + The same flag works with `npm update`. Then run `mise run check-dist`, and commit the rebuilt `dist/` with the lockfile. +- **The global npm CLI** in `setup-pnpm` and `setup-pnpm-bun`: an exact version, bumped by hand to a release at least 24 hours old (see the comment on the "Update npm" step). Both actions are deprecated in favor of mise, which uses the npm bundled with the locked Node. +- **Security updates:** once security updates are on in the repository settings, Dependabot ([`dependabot.yml`](../.github/dependabot.yml)) opens a pull request for every new security alert, at the root and in each action. Version updates stay manual. [Dependency audit](../.github/workflows/dependency-audit.yml) audits every lockfile each Monday, and keeps an issue open while advisories remain. Dependabot doesn't rebuild `dist/`: on its pull requests for an action, Check actions dist sync fails until someone runs `mise run check-dist` on the branch and pushes the rebuilt `dist/`. Its pull requests also skip the 24-hour cooldown, because GitHub ignores `cooldown` for security updates and Dependabot runs pnpm with `minimumReleaseAge=0`. Check the fixed version's publish date before you merge one. + +## Security linting + +Workflow audit ([`workflow-audit.yml`](../.github/workflows/workflow-audit.yml)) runs [zizmor](https://docs.zizmor.sh) on every pull request and every push to `main`, with the config in [`.github/zizmor.yml`](../.github/zizmor.yml). Third-party actions are pinned by SHA, and `withvariablelab/*` is allowed at `@main`. To run it locally: `pipx run --spec zizmor==1.30.1 zizmor .`. diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 13cbd75..fce93c7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,4 @@ -# Root tooling only (no packages: actions/* are standalone npm projects); see "Dependency cooldown" in the README. +# Root tooling only (no packages: actions/* are standalone npm projects); see "Dependency cooldown" in docs/development.md. minimumReleaseAge: 1440 minimumReleaseAgeExclude: - "@variablelab/*" From ae4f96bc168e8d461296be55c12dffa9542b501f Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Sun, 18 Oct 2026 07:52:39 -0500 Subject: [PATCH 3/3] docs: keep the setup comment in development.md within the code block's width Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XRCpaD5QTPJsEjnhikoV2S --- docs/development.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development.md b/docs/development.md index 68cbaf2..e9b2230 100644 --- a/docs/development.md +++ b/docs/development.md @@ -13,7 +13,7 @@ How to work on gh-actions in a clone. The [contributing guide](contributing.md) [mise](https://mise.jdx.dev) pins Node 24 and pnpm 10.30.3 in `mise.toml`, which needs mise 2026.6.6 or later. The repository has no `mise.lock`. `mise.toml` makes the root a mise monorepo with each folder in `actions/` as a config root, so a task run at the root runs in every action that declares it. It also puts `node_modules/.bin` on the `PATH`, so the tools run by name. ```sh -mise run setup # mise install, pnpm install at the root, then npm install in each Node action +mise run setup # mise install, pnpm install, then npm install in each Node action pnpm lefthook install # the git hooks, once per clone ```