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
1 change: 1 addition & 0 deletions .github/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This action holds a repository-admin token and writes repository settings, so th
- Supply chain. From the `build` branch onward, every ref a `uses:` pin can name - the `vX.Y.Z` release tags, the moving major, and the `latest` tag - points at a packaged commit on that branch: the recorded source commit's tree without its workflows, plus the bundle built from that source by the workflow run named in its provenance message; main carries no executable bundle, and the packaged commits carry no workflows (consumers run the action, never this repository's workflows). The tags cut before the `build` branch existed (v2.0.0 and earlier) point at release commits on main from when main still committed the bundle; nothing re-verifies them, and the release-tags ruleset is what keeps them where they are. The release-tags ruleset freezes version tags for everything except deliberate repository-admin action (the bypass exists for repair; the release workflow itself never moves a version tag, reruns verify the existing one byte-for-byte instead). A packaged commit whose bundle a rebuild of its recorded source's src/ does not reproduce, or whose tree is not that source's minus workflows plus the bundle, is a vulnerability. The release pipeline also attests every release asset (`lib/index.js` and `lib/settings.schema.json`) in one build-provenance attestation while the repository is public (GitHub offers no attestations elsewhere): fetch either from the `vX.Y.Z` tag or the release assets, then check it with `gh attestation verify
<artifact> -R vivswan/github-settings-as-code
--signer-workflow Vivswan/repo-platform/.github/workflows/fleet-release-publish.yml` (`-R` names the source repository; the signer is the fleet's publish workflow, the reusable workflow that ci.yml's `publish-release` job calls to run the attest step, and gh requires the signer flag whenever a reusable workflow signed the attestation: with `-R` alone it expects the signer inside this repository and fails), or without the attestations API via `--bundle` against the `attestation.json` release asset. A release finished by hand carries no attestation at all, and a packaged commit appended by hand no workflow-run trailer: both need the workflow's OIDC identity.
- npm provenance. Every version of the `@vivswan/github-settings-as-code` package is published from `ci.yml` through npm trusted publishing (OIDC; the package disallows tokens, so no registry token exists), and npm attaches a provenance attestation naming this repository and workflow to each: `npm audit signatures` in a project that installs it verifies them. The one exception is the bootstrap pre-release that created the package, published by hand once and recognizable by run number 0 in its version (`-main.0.g<sha7>`). Any other version without an attestation, or one whose attestation names another repository or workflow, is a vulnerability.

Fixes ship in the next release and are not backported; upgrade the `uses:` pin to pick them up.

Expand Down
6 changes: 6 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,9 @@ 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
# 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
# release lists `queue` under concurrency and the fleet step runs it.
- 'unexpected key "queue" for "concurrency" section'
669 changes: 556 additions & 113 deletions .github/scripts/release-pipeline.ts

Large diffs are not rendered by default.

103 changes: 102 additions & 1 deletion .github/workflows/post-green.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,115 @@ jobs:
if: steps.token.outputs.proceed == 'true'
with:
bun-version-file: .bun-version
- name: Build the bundle
- name: Build the bundle and the library
if: steps.token.outputs.proceed == 'true'
run: |
bun install --frozen-lockfile --ignore-scripts
bun run build:bundle
bun run build:lib
- name: Append this commit's packaged commit to build and point latest at the newest main source
if: steps.token.outputs.proceed == 'true'
env:
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 advance-build

# Publishes this green commit's library build to npm under the `next`
# dist-tag (release-pipeline.ts prerelease-version names the version)
# through trusted publishing: no registry token anywhere, provenance
# attached by npm. No permissions key, like build: a job asking for more
# than 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. Where a caller grants none (a fork, a ceiling change),
# the runner sets no ACTIONS_ID_TOKEN_REQUEST_URL, and the first step
# warns and skips every later one instead of failing the hook.
# The lane: ci.yml keys main runs by sha, so two green pushes can publish
# at once, and the registry has no compare-and-set, so a verdict read here
# must still hold when its publish lands. Both npm publish jobs (this one
# and update-release.yml's) take the same lane; no caller holds it. A lane
# keeps ONE pending job by default and drops the rest, which would lose a
# queued publish (a cancelled job here also fails the post-green call);
# queue: max keeps every queued publish. The lane serializes, it does not
# order: GitHub does not promise which queued run goes first, which is why
# the verdict step reads the registry's dist-tags under the lane and skips
# a version that is not newer than what next or latest already name.
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 }}
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
if: steps.oidc.outputs.proceed == 'true'
with:
bun-version-file: .bun-version
# 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 install --frozen-lockfile --ignore-scripts
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: a version already there (a rerun of
# this run) or a next or latest that is not older (a stale retry after
# newer runs published) publishes nothing, so next never moves back; 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
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 ;;
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
91 changes: 85 additions & 6 deletions .github/workflows/update-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@
# package -> find or append the build commit whose source is the merge commit, mint vX.Y.Z there ONCE, reconcile latest
# retag-major -> force-move vX there, then upload bundle and schema to the draft (a published release freezes its assets)
# verify -> confirm origin's refs and the draft's assets; the managed publish stage then attests and flips the release live
# publish-npm -> publish the library build to npm as the stable version through trusted publishing, once the refs check out
#
# Only ci.yml reaches this workflow, with the tag release-please just cut from its own merged PR, so a hand-edited
# manifest never mints a tag. A rerun converges while this release is its line's newest; once a NEWER release
# shipped, the major move stops by design.
# manifest never mints a tag. A rerun converges while this release is its line's newest (a version npm already holds
# is skipped); once a NEWER release shipped, the major move stops by design.

# Finishing a dead run by hand (prefer re-running its FAILED jobs: a hand run has no attestation.json, and a chain
# commit it appends carries no run trailer). NEVER publish before `package` has minted the tag: a tagless draft makes
# GitHub create vX.Y.Z lazily at the bundle-less merge commit on main.
# Finishing a dead run by hand (prefer re-running its FAILED jobs: a hand run has no attestation.json, a chain commit
# it appends carries no run trailer, and the npm publish has no hand path at all, only the workflow's OIDC identity may
# publish). NEVER publish before `package` has minted the tag: a tagless draft makes GitHub create vX.Y.Z lazily on main.
# git checkout <merge sha> (the draft's target commitish)
# bun install --frozen-lockfile --ignore-scripts && bun run build
# TAG=vX.Y.Z GITHUB_SHA=<merge sha> bun .github/scripts/release-pipeline.ts package
Expand Down Expand Up @@ -104,7 +105,7 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: .bun-version
- name: Confirm the published refs carry the bundle
- name: Confirm the published refs carry the build outputs
env:
TAG: ${{ inputs.tag }}
SOURCE_SHA: ${{ needs.package-release.outputs.source-sha }}
Expand All @@ -128,3 +129,81 @@ jobs:
echo "::error::release $TAG carries assets [$assets], expected [$expected]; re-run package-release before anything publishes."
exit 1
fi

# Publishes the release's library build to npm as the stable version through trusted publishing: no registry
# token, provenance attached by npm. Behind verify-release, so nothing reaches the registry before origin's refs
# 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
# 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:
if: github.repository == 'Vivswan/github-settings-as-code'
needs: [package-release, verify-release]
concurrency:
group: npm-publish
queue: max
cancel-in-progress: false
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.package-release.outputs.source-sha }}
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: .bun-version
# 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
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
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
run: |
bun install --frozen-lockfile --ignore-scripts
bun run build:lib
# scripts.prepare is dropped from the published manifest, as in
# post-green.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)
# publishes nothing, so no release ever displaces a newer one from
# latest (a pre-release there, the bootstrap's, is taken over). GITHUB_SHA on the
# publish line sets only the SLSA predicate's source digest; the
# sigstore certificate still carries this run's own head, so a release
# recovered by a newer push's run has provenance naming two commits.
- name: Publish the release to npm
env:
TAG: ${{ inputs.tag }}
SOURCE_SHA: ${{ needs.package-release.outputs.source-sha }}
run: |
verdict="$(GITHUB_SHA="$SOURCE_SHA" bun .github/scripts/release-pipeline.ts npm-verdict stable)"
case "$verdict" in
publish\ *)
npm pkg delete scripts.prepare
GITHUB_SHA="$SOURCE_SHA" npm publish ;;
skip\ *) echo "::warning::${verdict#skip }" ;;
*)
echo "unexpected npm-verdict output: $verdict"
echo "::error::npm-verdict printed neither publish nor skip; see the line above."
exit 1 ;;
esac
Loading
Loading