Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actionlint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ paths:
# prior step, and the runnable refs are the release build tags, so
# the static existence check is a false positive here.
- 'file "lib/index\.js" does not exist'
# The npm publish jobs in post-green.yml and update-release.yml share a
# The npm publish jobs in update-release-pr.yml and update-release.yml share a
# lane with `queue: max`, which keeps every queued publish instead of
# the one pending job a lane holds by default; actionlint 1.7.12, the
# newest release, does not know the key. Drop this once an actionlint
Expand Down
280 changes: 40 additions & 240 deletions .github/scripts/release-pipeline.ts

Large diffs are not rendered by default.

128 changes: 3 additions & 125 deletions .github/workflows/post-green.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Repo-owned, never overwritten by sync; ci.yml calls it after the all-green gate, before the release, with the
# judged sha. Main runs are keyed by sha (ci.yml's concurrency group), so runs for different shas never coalesce
# and successive merges can overlap; jobs here reconcile current state and stay idempotent.
# and successive merges can overlap; the job here reconciles current state and stays idempotent. Its one job packages
# the judged commit; the npm pre-release publishes from update-release-pr.yml, on release-please's refresh of the
# release PR, not from every green push.

name: Post Green

Expand Down Expand Up @@ -73,127 +75,3 @@ jobs:
SOURCE_SHA: ${{ inputs.sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: GITHUB_SHA="$SOURCE_SHA" bun .github/scripts/release-pipeline.ts package-commit

# No permissions key, like build: a job asking above the caller's ceiling fails the whole call, so the OIDC
# grant arrives by inheritance from the id-token: write the managed ci.yml gives the post-green call.
# The npm-publish lane exists because ci.yml keys main runs by sha (two green pushes can publish at once) and
# the registry has no compare-and-set: a verdict read under the lane still holds when its publish lands.
# no ACTIONS_ID_TOKEN_REQUEST_URL -> the caller granted none (a fork, a ceiling change); step one skips the rest
# shared lane -> update-release.yml's publish takes the same lane; no caller holds it
# queue: max -> a lane keeps ONE pending job by default; a dropped job fails the post-green call
# verdict compares the record -> the lane serializes but does not order; GitHub promises no queue order
# confirm holds the lane -> a job ends once the registry shows its publish, so the next holder's verdict sees it
publish-next:
if: github.repository == 'Vivswan/github-settings-as-code'
concurrency:
group: npm-publish
queue: max
cancel-in-progress: false
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check the caller grants an OIDC token
id: oidc
run: |
if [ -n "$ACTIONS_ID_TOKEN_REQUEST_URL" ]; then
echo "proceed=true" >> "$GITHUB_OUTPUT"
else
echo "::warning::this run has no OIDC token (the post-green call in the managed ci.yml grants no id-token: write);" \
"the library pre-release was not published to npm." \
"Add id-token: write to that call's permissions in Vivswan/repo-platform to publish every green push to @next."
echo "proceed=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: steps.oidc.outputs.proceed == 'true'
with:
ref: ${{ inputs.sha }}
# The version counts main's commits under this one and the verdict places published sources by ancestry; a shallow checkout can do neither.
fetch-depth: 0
persist-credentials: false
- uses: ./.github/actions/setup
if: steps.oidc.outputs.proceed == 'true'
# registry-url writes the .npmrc npm publishes through; with no
# NODE_AUTH_TOKEN the action leaves a placeholder there, which npm's
# OIDC exchange replaces.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: steps.oidc.outputs.proceed == 'true'
with:
node-version: 24
registry-url: https://registry.npmjs.org
# Trusted publishing (and its provenance) exists from npm 11.5.1 on; an
# older bundled npm is upgraded once, then held to the floor.
- name: Require an npm that publishes through OIDC
if: steps.oidc.outputs.proceed == 'true'
run: |
floor=11.5.1
below_floor() { [ "$(printf '%s\n' "$floor" "$(npm --version)" | sort -V | head -n1)" != "$floor" ]; }
if below_floor; then
npm install -g npm@latest
fi
if below_floor; then
echo "::error::npm $(npm --version) cannot publish through OIDC; trusted publishing needs npm $floor or newer."
exit 1
fi
- name: Build the library
if: steps.oidc.outputs.proceed == 'true'
run: bun run build:lib
# scripts.prepare is dropped from the published manifest: it installs
# lefthook, a devDependency the tarball does not carry, and npm blocks
# install scripts from a provenance-attested package anyway.
# The verdict reads the registry's origin record (the CDN's copy lags a
# publish by up to 300 s) and places every published pre-release by the
# ancestry of the source sha it names: a version already there (a rerun
# of this run) or a pre-release of a descendant of this commit (a stale
# retry after newer runs published) publishes nothing, so next never
# moves back on what the run can see. It then walks the merges to main
# after the source of the build next names, up to this commit, over what
# the tarball ships and what builds it (package.json's files list beside
# the script's NEXT_BUILD_INPUTS): when none touched any of it, docs
# alone, this run publishes nothing either; a base the checkout cannot
# place publishes with a notice saying why. A registry error stops the job instead of
# publishing blind. npm's provenance names the commit in GITHUB_SHA, so
# the source is passed in.
- name: Publish the pre-release under the next dist-tag
id: publish
if: steps.oidc.outputs.proceed == 'true'
env:
SOURCE_SHA: ${{ inputs.sha }}
run: |
verdict="$(GITHUB_SHA="$SOURCE_SHA" bun .github/scripts/release-pipeline.ts npm-verdict next)"
case "$verdict" in
publish\ *)
npm version "${verdict#publish }" --no-git-tag-version
npm pkg delete scripts.prepare
GITHUB_SHA="$SOURCE_SHA" npm publish --tag next
echo "published=true" >> "$GITHUB_OUTPUT" ;;
skip\ *) echo "::notice::${verdict#skip }" ;;
*)
echo "unexpected npm-verdict output: $verdict"
echo "::error::npm-verdict printed neither publish nor skip; see the line above."
exit 1 ;;
esac
# npm makes a publish readable asynchronously. This step holds the lane
# until the record shows the version (15 reads, 20 s apart), so the next
# holder's verdict sees it; a record that never shows it warns. It then
# fails the job if the record holds a descendant's pre-release and next
# names none: OIDC authenticates npm publish alone, not npm dist-tag add,
# so no tag is moved here and the next green push that changes the
# shipped surface moves next forward instead. A rerun of the failed run publishes nothing, skips this step,
# and passes.
- name: Confirm the registry shows the publish and next moved forward
if: steps.publish.outputs.published == 'true'
env:
SOURCE_SHA: ${{ inputs.sha }}
run: |
confirmed="$(GITHUB_SHA="$SOURCE_SHA" bun .github/scripts/release-pipeline.ts npm-confirm next)"
case "$confirmed" in
settled\ *) echo "::notice::${confirmed#settled }" ;;
unsettled\ *) echo "::warning::${confirmed#unsettled }" ;;
behind\ *)
echo "::error::${confirmed#behind }"
exit 1 ;;
*)
echo "unexpected npm-confirm output: $confirmed"
echo "::error::npm-confirm printed neither settled, unsettled, nor behind; see the line above."
exit 1 ;;
esac
135 changes: 133 additions & 2 deletions .github/workflows/update-release-pr.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
# The release-PR hook of the release pipeline: repo-owned (a generated-once starter, never overwritten by sync),
# called by the managed ci.yml whenever release-please creates or refreshes the release PR, independently of any
# release cut. Its one job is the boundary anchor (anchorReleasePr in .github/scripts/release-pipeline.ts): called
# in the run that refreshed the PR, GITHUB_SHA is exactly the main head the refresh was built on.
# release cut. Inside a called workflow github.sha is the caller's: the push to main that release-please judged, the
# head the refresh was built on. Two independent jobs share that sha and nothing else:
#
# anchor -> the boundary anchor (anchorReleasePr in .github/scripts/release-pipeline.ts) inside the PR branch
# publish-next -> the library pre-release to npm under the `next` dist-tag
#
# The publish lives here because release-please's refresh IS the releasable signal: release-please-config.json leaves
# always-update off, so the PR is refreshed only when the release notes change, i.e. when a commit of a type its
# changelog shows lands (feat, fix, perf, revert, or a breaking marker). A push of hidden types alone (test, docs, chore,
# build, ci, refactor) refreshes nothing and publishes nothing; no check of this repository's own decides a publish.
#
# The anchor is pushed with the default github.token, which starts no workflows, so the PR's checks do not re-run
# on the anchored head; anchor-check's failure message says to close and reopen the PR.
Expand Down Expand Up @@ -33,3 +41,126 @@ jobs:
install: "false"
- name: Anchor the boundary inside the release PR branch
run: bun .github/scripts/release-pipeline.ts anchor

# The npm-publish lane exists because the registry has no compare-and-set: the pre-publish guard's read still holds
# when its publish lands, whichever publisher (this job, update-release.yml's) took the lane before it.
# no ACTIONS_ID_TOKEN_REQUEST_URL -> the caller granted none (a fork, a ceiling change); step one skips the rest
# shared lane -> update-release.yml's publish takes the same lane; no caller holds it
# queue: max -> a lane keeps ONE pending job by default; a dropped job fails the call
# guard reads the record -> the lane serializes but does not order; GitHub promises no queue order
# confirm holds the lane -> a job ends once the registry shows its publish, so the next holder's guard sees it
# No permissions key: a job asking above the caller's ceiling fails the whole call, anchor included, so the OIDC
# grant arrives by inheritance from the id-token: write the managed ci.yml gives this call, and a ceiling without
# it reaches step one's warn-and-skip instead of a validation failure.
publish-next:
if: github.repository == 'Vivswan/github-settings-as-code'
concurrency:
group: npm-publish
queue: max
cancel-in-progress: false
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check the caller grants an OIDC token
id: oidc
run: |
if [ -n "$ACTIONS_ID_TOKEN_REQUEST_URL" ]; then
echo "proceed=true" >> "$GITHUB_OUTPUT"
else
echo "::warning::this run has no OIDC token (the update-release-pr call in the managed ci.yml grants no id-token: write);" \
"the library pre-release was not published to npm." \
"Add id-token: write to that call's permissions in Vivswan/repo-platform to publish every release-PR refresh to @next."
echo "proceed=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: steps.oidc.outputs.proceed == 'true'
with:
ref: ${{ github.sha }}
# The version counts main's commits under this one and the guard places published sources by ancestry; a shallow checkout can do neither.
fetch-depth: 0
persist-credentials: false
- uses: ./.github/actions/setup
if: steps.oidc.outputs.proceed == 'true'
# registry-url writes the .npmrc npm publishes through; with no
# NODE_AUTH_TOKEN the action leaves a placeholder there, which npm's
# OIDC exchange replaces.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: steps.oidc.outputs.proceed == 'true'
with:
node-version: 24
registry-url: https://registry.npmjs.org
# Trusted publishing (and its provenance) exists from npm 11.5.1 on; an
# older bundled npm is upgraded once, then held to the floor.
- name: Require an npm that publishes through OIDC
if: steps.oidc.outputs.proceed == 'true'
run: |
floor=11.5.1
below_floor() { [ "$(printf '%s\n' "$floor" "$(npm --version)" | sort -V | head -n1)" != "$floor" ]; }
if below_floor; then
npm install -g npm@latest
fi
if below_floor; then
echo "::error::npm $(npm --version) cannot publish through OIDC; trusted publishing needs npm $floor or newer."
exit 1
fi
- name: Build the library
if: steps.oidc.outputs.proceed == 'true'
run: bun run build:lib
# scripts.prepare is dropped from the published manifest: it installs
# lefthook, a devDependency the tarball does not carry, and npm blocks
# install scripts from a provenance-attested package anyway.
# The guard is a safety on npm state, not a decision about whether to
# publish (the refresh that called this workflow decided that): it reads
# the registry's origin record (the CDN's copy lags a publish by up to
# 300 s) and places every published pre-release by the ancestry of the
# source sha it names. A version already there (a rerun of this run) or
# a pre-release of a descendant of this commit (a stale retry after
# newer runs published) publishes nothing, so next never moves back on
# what the run can see; a source the checkout cannot place is ignored
# with a notice. A registry error stops the job instead of publishing
# blind. npm's provenance names the commit in GITHUB_SHA, so the source
# is passed in.
- name: Publish the pre-release under the next dist-tag
id: publish
if: steps.oidc.outputs.proceed == 'true'
env:
SOURCE_SHA: ${{ github.sha }}
run: |
verdict="$(GITHUB_SHA="$SOURCE_SHA" bun .github/scripts/release-pipeline.ts npm-verdict next)"
case "$verdict" in
publish\ *)
npm version "${verdict#publish }" --no-git-tag-version
npm pkg delete scripts.prepare
GITHUB_SHA="$SOURCE_SHA" npm publish --tag next
echo "published=true" >> "$GITHUB_OUTPUT" ;;
skip\ *) echo "::notice::${verdict#skip }" ;;
*)
echo "unexpected npm-verdict output: $verdict"
echo "::error::npm-verdict printed neither publish nor skip; see the line above."
exit 1 ;;
esac
# npm makes a publish readable asynchronously. This step holds the lane
# until the record shows the version (15 reads, 20 s apart), so the next
# holder's guard sees it; a record that never shows it warns. It then
# fails the job if the record holds a descendant's pre-release and next
# names none: OIDC authenticates npm publish alone, not npm dist-tag add,
# so no tag is moved here and the next release-PR refresh moves next
# forward instead. A rerun of the failed run publishes nothing, skips this step,
# and passes.
- name: Confirm the registry shows the publish and next moved forward
if: steps.publish.outputs.published == 'true'
env:
SOURCE_SHA: ${{ github.sha }}
run: |
confirmed="$(GITHUB_SHA="$SOURCE_SHA" bun .github/scripts/release-pipeline.ts npm-confirm next)"
case "$confirmed" in
settled\ *) echo "::notice::${confirmed#settled }" ;;
unsettled\ *) echo "::warning::${confirmed#unsettled }" ;;
behind\ *)
echo "::error::${confirmed#behind }"
exit 1 ;;
*)
echo "unexpected npm-confirm output: $confirmed"
echo "::error::npm-confirm printed neither settled, unsettled, nor behind; see the line above."
exit 1 ;;
esac
4 changes: 2 additions & 2 deletions .github/workflows/update-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ jobs:
# token, provenance attached by npm. Behind verify-release, so nothing reaches the registry before the draft's
# assets check out. Until the owner's one-time npm setup exists this job fails and holds the draft; a rerun converges.
# package-release sits in needs for its output alone (a job reads outputs only from its direct dependencies).
# The lane is the one post-green.yml's publish-next takes: the registry has no compare-and-set, so the two
# The lane is the one update-release-pr.yml's publish-next takes: the registry has no compare-and-set, so the two
# publishers never overlap and a verdict still holds when its publish lands; queue: max keeps every queued
# publish (a lane drops all but one pending job by default).
publish-npm:
Expand Down Expand Up @@ -167,7 +167,7 @@ jobs:
- name: Build the library
run: bun run build:lib
# scripts.prepare is dropped from the published manifest, as in
# post-green.yml's publish-next.
# update-release-pr.yml's publish-next.
# The verdict holds the built package.json to the tag and reads the
# registry: a version already there (a rerun) or a latest that is a
# newer release (an older release's job rerun after a newer release)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Apply declarative repository settings from `.github/settings.yml`: a loud, state

The same engine is the npm package `@vivswan/github-settings-as-code` (ESM, Node 22.14 or newer): validate, merge, check, and apply from your own code.

- `npm install @vivswan/github-settings-as-code` installs the released version; `@next` installs the newest green `main` commit as a pre-release. The [library reference](docs/reference/library.md) has the API by group and the versioning rules.
- `npm install @vivswan/github-settings-as-code` installs the released version; `@next` installs the pre-release of the `main` commit release-please last refreshed the release PR on ([Versioning](docs/reference/library.md#versioning)). The [library reference](docs/reference/library.md) has the API by group and the versioning rules.
- `npx @vivswan/github-settings-as-code@next check --repository o/r --settings-file .github/settings.yml` runs the action's check from a terminal. The [command line guide](docs/start/cli.md) has every command.

## Docs
Expand Down
Loading
Loading