Repository navigation
chore(vale): accept upstream Vale 3.22.0 #12
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
| # SPDX-License-Identifier: MIT | |
| # Vale platform packages — detect upstream releases, then publish @taskless/vale-*. | |
| # | |
| # Standalone by design. This workflow does not touch the CLI release workflows | |
| # and they do not touch these packages: the platform packages are in the | |
| # changesets `ignore` list, their versions are stamped here rather than bumped | |
| # by a changeset, and release-cli.yml's "is main's version on npm yet?" check | |
| # reads packages/cli/package.json only. | |
| # | |
| # TWO PHASES, because the trust boundary is code review (design D6): | |
| # | |
| # detect Runs on a schedule with NO npm credential and NO OIDC identity. It | |
| # compares the latest upstream Vale release against the version | |
| # pinned in .github/scripts/vale-manifest.json. When upstream is | |
| # ahead it opens a pull request that updates the pinned version and | |
| # all six SHA256 digests, taken from upstream's own checksums file, | |
| # and carries upstream's release notes so a reviewer can see what the | |
| # digests are for. It publishes nothing. | |
| # | |
| # That pull request lives on ONE ROLLING BRANCH, | |
| # `vendor/vale/republish`, rebuilt and retitled as upstream moves | |
| # rather than accumulating a branch per release. `vendor-pr.cjs` owns | |
| # that lifecycle and its two force-push guards, and every vendor | |
| # workflow uses it, so they behave identically. | |
| # | |
| # THIS IS THE PRODUCER HALF ONLY. Publishing a platform package | |
| # changes nothing for a consumer (see APPROVAL POLICY below); moving | |
| # the CLI's pins onto the published set is `vale-upgrade.yml`, on | |
| # `vendor/vale/upgrade`. Do not add that here — it needs no npm | |
| # credential and belongs nowhere near one. | |
| # | |
| # publish Runs on the push to main that merges that pull request — i.e. once | |
| # a human has reviewed the digests. Split further into `gate`, | |
| # `prepare`, and `publish` below, where `gate` decides whether the | |
| # pinned version still needs publishing at all. | |
| # | |
| # A single job that discovered a digest and then verified downloads against the | |
| # digest it had just discovered would verify nothing. Splitting the phases is | |
| # what makes the automation trustworthy: nothing is published on bytes nobody | |
| # signed off on, and nobody has to notice a Vale release for the process to run. | |
| # | |
| # WHAT BOUNDS A RUN is the upstream-version comparison plus the `gate` job, and | |
| # the distinction between them is worth stating precisely because half of it is | |
| # a trap (design D5). | |
| # | |
| # A check against the STAMPED version — the thing release-cli.yml uses — cannot work | |
| # here: every publish stamps <valeVersion>-<yyyymmddhhmmss>, a version npm has | |
| # never seen, so it would answer "not published" every time and could never | |
| # suppress anything. | |
| # | |
| # A check against the BASE version is a different question and does work. "Has | |
| # anything been published for Vale 3.17.1?" is satisfied by a published 3.17.1 | |
| # or any 3.17.1-* stamp, which is exactly the set this workflow can mint for it. | |
| # `gate` runs that check, because the push trigger below fires on ANY edit to | |
| # vale-manifest.json and a `paths:` filter cannot see why the file changed — a | |
| # reworded comment would otherwise publish six packages. An explicit dispatch | |
| # passes --force and is never suppressed. | |
| # | |
| # WHY prepare AND publish ARE SEPARATE JOBS: `prepare` downloads third-party | |
| # bytes off the internet. It holds `contents: read`, no environment, and no | |
| # id-token, so it cannot publish or mint a token no matter what it downloads. It | |
| # verifies every archive against the committed digest, aborts the run on a | |
| # mismatch before anything is unpacked, and hands over `npm pack` tarballs. The | |
| # credentialed `publish` job therefore only ever handles bytes that already | |
| # matched a reviewed digest and are already sealed into a tarball. It does not | |
| # even check out the repository. | |
| # | |
| # WHY PACK BEFORE UPLOADING: actions/upload-artifact does not preserve file | |
| # modes, and the Vale executable has to reach npm with its executable bit set. | |
| # `npm pack` records modes inside the .tgz, so packing first and shipping the | |
| # tarball through the artifact keeps 0755 intact end to end. | |
| # | |
| # PUBLISHING IDENTITY is inherited, not reinvented: npm trusted publishing, a | |
| # short-lived OIDC-minted token, with no stored NPM_TOKEN anywhere. That binding | |
| # is registered PER PACKAGE on npmjs.com, and it names this workflow's FILENAME | |
| # and its environment — so renaming this file or changing `environment:` below | |
| # breaks every publish until the six bindings are re-registered to match. There | |
| # is nothing to bind until the package name exists, so the FIRST publish of each | |
| # of the six names is a deliberate one-time manual step by a maintainer, who | |
| # then registers the trusted publisher. This workflow assumes that has already | |
| # happened for every name in the manifest, and there is no fallback token path | |
| # here on purpose. | |
| # | |
| # APPROVAL POLICY: the environment is `npm-autopublish`, which has NO required | |
| # reviewer — unlike `npm-production`, where @taskless/cli still waits for a | |
| # click. That is not a relaxation, because the review already happened | |
| # somewhere better: the manifest-update pull request IS the gate. A human reads | |
| # the upstream Vale version and all six SHA256 digests there, and only merging | |
| # it can start a publish at all. An environment approval would be a second copy | |
| # of that same gate, asked at a point where nothing is left to decide — the | |
| # bytes were fixed when the digests were reviewed, and a reviewer standing at | |
| # the publish step has no new information to act on. Approvals that decide | |
| # nothing get clicked without being read, which weakens the one that matters. | |
| # | |
| # What still bounds an unattended publish: the deployment branch policy on | |
| # `npm-autopublish` restricts it to `main`, so no branch can reach the credential | |
| # by adding a job that names the environment; and publishing a platform package | |
| # changes no consumer, because the CLI pins each one exactly and a new version | |
| # reaches a user only when someone reviews a bump to that pin. Do not "simplify" | |
| # this back onto `npm-production` to make the two release workflows match — they | |
| # differ on purpose, and the reason is the pin, not the package. | |
| # | |
| # BOOTSTRAPPING A NEW PACKAGE NAME, once: | |
| # | |
| # node .github/scripts/vale-prepare.cjs --out .vale-dist | |
| # npm publish --access public --tag latest .vale-dist/<name>.tgz | |
| # git checkout -- packages/vale-*/package.json # drop the local stamp | |
| # | |
| # Publish the packed tarball rather than the directory: the committed | |
| # package.json carries the placeholder version 0.0.0 and no binary, so a bare | |
| # `npm publish` in a package directory would burn the name on an empty 0.0.0. | |
| # Provenance is omitted from the manual step (it needs a CI OIDC identity); | |
| # register the trusted publisher afterwards and every later publish gets it. | |
| # | |
| # Action refs are pinned to commit SHAs; the trailing comment records the tag. | |
| name: Release Vale | |
| on: | |
| # Detect only. Weekly is a deliberate cadence choice: a Vale security release | |
| # should be mirrored faster than that, which is what the manual `detect` | |
| # dispatch below is for. | |
| schedule: | |
| - cron: "23 7 * * 1" | |
| # Publish. This path fires exactly when a reviewed manifest change lands on | |
| # main, which is the merge of a detect pull request. An ordinary push to main | |
| # does not touch the manifest, so it starts no job here at all. | |
| push: | |
| branches: [main] | |
| paths: | |
| - ".github/scripts/vale-manifest.json" | |
| workflow_dispatch: | |
| inputs: | |
| phase: | |
| description: "detect: check upstream and open a PR. publish: stamp and publish the pinned version." | |
| type: choice | |
| options: | |
| - detect | |
| - publish | |
| default: detect | |
| # No workflow-wide grants; each job asks for exactly what it needs. | |
| permissions: {} | |
| # One at a time, so a scheduled detect cannot race a publish. | |
| concurrency: release-vale | |
| jobs: | |
| detect: | |
| name: Detect upstream Vale | |
| if: >- | |
| github.event_name == 'schedule' || | |
| (github.event_name == 'workflow_dispatch' && inputs.phase == 'detect') | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write # push the vendor/vale/republish branch | |
| pull-requests: write # open the manifest update PR | |
| steps: | |
| # Credentials persist here because this job pushes a branch. It holds no | |
| # npm identity and no id-token, and it never runs downloaded code. | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version-file: .nvmrc | |
| # No install step: the script is zero-dependency CommonJS. GITHUB_TOKEN is | |
| # passed only to raise the GitHub API rate limit; the endpoints are public. | |
| # | |
| # `--notes-out` writes upstream's release notes for the proposed version | |
| # to a file, which the next step appends to the pull request body. A | |
| # reviewer's job here is to decide whether these bytes should be | |
| # publishable, and a bare version number with six digests does not say | |
| # whether the release is a security fix or a docs pass. The notes arrive | |
| # as a FILE rather than a step output because they are third-party | |
| # Markdown: a $GITHUB_OUTPUT line is delimited text that a release body | |
| # containing the delimiter can break out of. | |
| - id: detect | |
| run: | | |
| node .github/scripts/vale-detect.cjs \ | |
| --write --notes-out "${RUNNER_TEMP}/vale-release-notes.md" | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # Every value reaching the shell below goes through `env:` rather than | |
| # `${{ }}` interpolation into the script body. The version has already been | |
| # validated as major.minor.patch by the script, but the pattern is the | |
| # rule regardless of the value. | |
| # | |
| # "accept upstream", not "pin". This proposal changes NOTHING a consumer | |
| # installs: it records which upstream release we are willing to package | |
| # and republish. The pins a user resolves move in `vale-upgrade.yml`, | |
| # whose PR says "upgrade to". Two stages, two verbs, so a title says which | |
| # one you are looking at — they otherwise differ only in the word `pin`, | |
| # which was on the wrong one. | |
| # | |
| # The branch and pull request lifecycle is `vendor-pr.cjs`, shared with | |
| # every vendor workflow: one rolling `vendor/vale/republish` branch, | |
| # rebuilt from `main`, retitled and rewritten as upstream moves, and | |
| # force-pushed only when the proposal actually changed and only when this | |
| # workflow wrote what is already there. | |
| - name: Propose the bump | |
| if: steps.detect.outputs.update == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| VALE_VERSION: ${{ steps.detect.outputs.vale_version }} | |
| PINNED_VERSION: ${{ steps.detect.outputs.pinned_version }} | |
| run: | | |
| set -euo pipefail | |
| # Composed as a FILE, not as an argument. The preamble is ours and the | |
| # notes appended below it are upstream's, and the quoted heredoc plus | |
| # `cat` means neither the shell nor gh ever interprets the latter. | |
| body="${RUNNER_TEMP}/vale-pr-body.md" | |
| cat > "$body" <<'BODY' | |
| Upstream Vale is ahead of the version this repository packages. | |
| This updates `.github/scripts/vale-manifest.json` to the new upstream | |
| version and replaces every platform's SHA256 with the digest from | |
| upstream's `vale_<version>_checksums.txt`. | |
| **Reviewing this is the trust boundary.** Merging it authorizes the | |
| publish phase to download those exact archives and package them; a | |
| download that does not match a digest here aborts the run and | |
| publishes nothing. Check the digests against upstream's checksums file | |
| for the release before approving. | |
| Merging this triggers the publish phase, which stamps every platform | |
| package `<valeVersion>-<yyyymmddhhmmss>` and publishes the set. That | |
| publish is inert on its own: `@taskless/cli` pins exact versions, so | |
| nothing reaches a consumer until that pin is deliberately bumped — | |
| which is what `vale-upgrade.yml` proposes, separately, once this has | |
| merged and published. Merging THIS changes nobody's install. | |
| This pull request rolls: if upstream releases again before it merges, | |
| the branch, title, and body are rewritten to the newer version rather | |
| than a second pull request being opened. Push a commit to the branch | |
| and that stops — the workflow will not force-push over a commit it did | |
| not write. | |
| BODY | |
| # Absent only if the detect step reported ahead and then wrote no | |
| # notes, which it cannot — so warn rather than skip silently. | |
| notes="${RUNNER_TEMP}/vale-release-notes.md" | |
| if [ -f "$notes" ]; then | |
| printf '\n---\n\n' >> "$body" | |
| cat "$notes" >> "$body" | |
| else | |
| echo "::warning::no release notes file at ${notes}" | |
| fi | |
| node .github/scripts/vendor-pr.cjs \ | |
| --branch vendor/vale/republish \ | |
| --title "chore(vale): accept upstream Vale ${VALE_VERSION}" \ | |
| --message "chore(vale): accept upstream Vale ${VALE_VERSION}" \ | |
| --body-file "$body" \ | |
| --label skip-changeset \ | |
| -- .github/scripts/vale-manifest.json | |
| # Is there anything to publish? The push trigger fires on ANY edit to | |
| # vale-manifest.json — a reworded comment, a reformat, a digest correction — | |
| # and it cannot tell those from a version bump. Without this gate each of them | |
| # publishes six packages at a fresh <valeVersion>-<timestamp> stamp, which | |
| # nothing downstream can absorb because every stamp is novel by construction. | |
| # | |
| # Cheap on purpose: it reads the registry and decides BEFORE `prepare` | |
| # downloads ~60 MB of third-party archives. Credential-free, like prepare. | |
| gate: | |
| name: "publish gate" | |
| if: >- | |
| github.event_name == 'push' || | |
| (github.event_name == 'workflow_dispatch' && inputs.phase == 'publish') | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read # checkout only | |
| outputs: | |
| should_publish: ${{ steps.gate.outputs.should_publish }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version-file: .nvmrc | |
| # --force on dispatch: an explicit human request publishes even when the | |
| # pinned version is already out. Only the automatic push path is gated. | |
| - id: gate | |
| run: | | |
| node .github/scripts/vale-gate.cjs \ | |
| ${{ github.event_name == 'workflow_dispatch' && '--force' || '' }} | |
| # Credential-free. Downloads third-party bytes, verifies them against the | |
| # reviewed digests, and produces tarballs. Cannot publish anything. | |
| prepare: | |
| name: Fetch, verify, stamp, pack | |
| needs: gate | |
| if: needs.gate.outputs.should_publish == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read # checkout only | |
| outputs: | |
| version: ${{ steps.prepare.outputs.version }} | |
| vale_version: ${{ steps.prepare.outputs.vale_version }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false # nothing here writes to git | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version-file: .nvmrc | |
| # No dependency install at all: the script is zero-dependency CommonJS and | |
| # unpacks with `tar` and `unzip`, both present on ubuntu-latest. Nothing | |
| # from npm runs in this job beyond `npm pack` on our own packages. | |
| - id: prepare | |
| run: node .github/scripts/vale-prepare.cjs --out .vale-dist | |
| # `include-hidden-files` is REQUIRED because the output directory is | |
| # dot-prefixed. upload-artifact has excluded hidden files by default since | |
| # v4.4, and a leading-dot DIRECTORY makes everything beneath it hidden — | |
| # so `.vale-dist/*.tgz` silently matched nothing even though `prepare` | |
| # had just written six tarballs there. `if-no-files-found: error` is what | |
| # turned that into a failed run rather than an empty artifact handed to | |
| # the publish job; keep both. | |
| - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: vale-tarballs | |
| path: .vale-dist/*.tgz | |
| include-hidden-files: true | |
| if-no-files-found: error | |
| retention-days: 1 | |
| publish: | |
| name: Publish to npm | |
| needs: prepare | |
| # DELIBERATE DUPLICATE of the `npm-autopublish` deployment branch policy. | |
| # The duplication IS the point: two independent controls, one of which lives | |
| # in a settings page and one of which lives here, in a file that cannot | |
| # change without code review. Do not delete this as redundant with the | |
| # environment — the environment is exactly what it is defending against. | |
| # | |
| # This became load-bearing when the required reviewer went away. Under | |
| # `npm-production` an off-`main` publish had to get past the branch policy | |
| # AND a human clicking approve; with the reviewer gone the branch policy is | |
| # the only thing left, and a settings edit by someone who does not know it | |
| # is load-bearing silently enables publishing from any branch. That failure | |
| # is an ABSENCE, so nothing goes red when it happens. | |
| # | |
| # One expression covers both ways this job can be reached. The `push` path | |
| # is already `branches: [main]`, so the guard is a no-op there and costs | |
| # nothing. The `workflow_dispatch` path is the real case: a dispatch runs | |
| # from whatever ref it was launched on, and `github.ref` is that ref. | |
| # | |
| # Only `publish` is guarded, on purpose. `gate` and `prepare` hold no | |
| # environment, no id-token, and only `contents: read`, so running them off | |
| # `main` publishes nothing — it is a useful dry run of the digest | |
| # verification and the pack. Guarding them instead would protect this job | |
| # only by inference through `needs:`, which is one control with a spare | |
| # rather than two controls. | |
| if: github.ref == 'refs/heads/main' | |
| runs-on: ubuntu-latest | |
| # The scoping and audit boundary for the release, and where the npm trusted | |
| # publisher for each @taskless/vale-* package is bound. Reviewer-free by | |
| # design (see APPROVAL POLICY in the header): the manifest pull request is | |
| # the gate, and this environment's branch policy still confines the | |
| # credential to `main`. Changing this name requires re-registering all six | |
| # bindings on npmjs.com first — the environment is part of the binding. | |
| environment: npm-autopublish | |
| permissions: | |
| contents: read | |
| id-token: write # OIDC → short-lived npm auth + build provenance | |
| steps: | |
| # Deliberately no checkout. This job publishes tarballs the previous job | |
| # already verified and sealed; it has no reason to hold repository source | |
| # while an OIDC identity exists. | |
| # | |
| # setup-node >= v7 is the floor here; release-cli.yml's publish job | |
| # explains why (`always-auth` in .npmrc until v6.1, a dummy | |
| # NODE_AUTH_TOKEN in env until v7). The warning in issue #294 came from | |
| # this step's .npmrc under v4.4.0. | |
| # | |
| # A literal major rather than `node-version-file: .nvmrc`, because there | |
| # is no checkout here for the file to be read from. Keep it equal to | |
| # .nvmrc; this is the one place in the workflows the number is repeated. | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version: 24 | |
| registry-url: https://registry.npmjs.org | |
| - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: vale-tarballs | |
| path: tarballs | |
| # OIDC trusted publishing and provenance need npm >= 11.5.1. Pinned, not | |
| # @latest, so publish behavior cannot change unreviewed. --ignore-scripts: | |
| # no lifecycle code runs while the OIDC identity is available. | |
| - run: npm install -g npm@12.0.1 --ignore-scripts | |
| # `--tag latest` is required, not cosmetic: every version here is a semver | |
| # prerelease by design (D4), and npm refuses to publish a prerelease onto | |
| # the default tag without being told. Tagging them `latest` is right — | |
| # these are not preview builds, they are the only builds, and the | |
| # prerelease component exists to defeat range matching, not to signal | |
| # instability. | |
| - name: Publish every platform tarball | |
| working-directory: tarballs | |
| env: | |
| STAMPED_VERSION: ${{ needs.prepare.outputs.version }} | |
| run: | | |
| shopt -s nullglob | |
| tarballs=(*.tgz) | |
| if [ "${#tarballs[@]}" -eq 0 ]; then | |
| echo "No tarballs in the artifact — refusing to report success." >&2 | |
| exit 1 | |
| fi | |
| echo "Publishing ${#tarballs[@]} package(s) at ${STAMPED_VERSION}." | |
| # The loop deliberately does not stop at the first failure, and the | |
| # step deliberately does not lean on `set -e` here. Six sequential | |
| # publishes are six chances for a transient registry error, and | |
| # aborting midway leaves the set partially released — some platforms | |
| # resolvable at this version, others not, which is the one state the | |
| # CLI's exact cross-package pins cannot tolerate. Attempting all six | |
| # and failing at the end means one re-run has at most the stragglers | |
| # left to do. | |
| # | |
| # That re-run is what the `npm view` guard is for: a tarball is | |
| # immutable and its version is stamped once, in prepare, so a package | |
| # already at this version was published by an earlier attempt of this | |
| # same release. Skipping it is idempotent, where re-publishing would | |
| # fail with "cannot publish over the previously published version" | |
| # and strand every package after it. | |
| failed=() | |
| for tarball in "${tarballs[@]}"; do | |
| echo "::group::$tarball" | |
| name="$(tar -xzOf "$tarball" package/package.json | node -e 'let raw = ""; process.stdin.on("data", (chunk) => { raw += chunk; }).on("end", () => { console.log(JSON.parse(raw).name); });')" | |
| if npm view "${name}@${STAMPED_VERSION}" version >/dev/null 2>&1; then | |
| echo "${name}@${STAMPED_VERSION} is already published — skipping." | |
| elif ! npm publish --provenance --access public --tag latest "./$tarball"; then | |
| echo "::error::failed to publish ${name}@${STAMPED_VERSION}" | |
| failed+=("$name") | |
| fi | |
| echo "::endgroup::" | |
| done | |
| if [ "${#failed[@]}" -ne 0 ]; then | |
| echo "Failed to publish ${#failed[@]} of ${#tarballs[@]} package(s) at ${STAMPED_VERSION}:" >&2 | |
| printf ' %s\n' "${failed[@]}" >&2 | |
| echo "Re-run this job to retry them; already-published packages are skipped." >&2 | |
| exit 1 | |
| fi | |
| echo "All ${#tarballs[@]} package(s) are published at ${STAMPED_VERSION}." |