Skip to content
Merged
6 changes: 3 additions & 3 deletions .github/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ This action holds a repository-admin token and writes repository settings, so th
- Token handling. The token travels only in the Authorization header and is never printed, not even in debug traces. Any path that puts it in logs, annotations, the step summary, or outputs is a vulnerability.
- Workflow-command injection. API responses and settings-file content are echoed into annotations and the step summary, escaped for workflow commands (%, CR, LF) and for summary tables (pipes, backslashes). Input that breaks out of that escaping and injects commands or forged log lines is a vulnerability.
- Settings escalation. A crafted settings file must never touch a repository or setting it does not declare, nor bypass the preflight barrier or the required-sections policy.
- Supply chain. A packaged commit whose bundle a rebuild of its recorded source does not reproduce, or whose tree is not that source's minus workflows plus the bundle, is a vulnerability. The next section says what each ref points at and how to verify it.
- npm provenance. Every CI-published version of `@vivswan/github-settings-as-code` carries an npm provenance attestation naming this repository and workflow, which `npm audit signatures` checks in a project that installs it. A version without one, or whose attestation names another repository or workflow, is a vulnerability; the one exception is the hand-published bootstrap pre-release, recognizable by run number 0 in its version (`-main.0.g<sha7>`).
- Supply chain. A packaged commit whose bundle a rebuild of its parent (its source commit) does not reproduce, or whose tree is not that source's plus the bundle and library build, minus `package.json`'s preparation scripts (`prepare` and the install hooks, which would make npm rebuild a `github:` install), is a vulnerability. The next section says what each ref points at and how to verify it.
- npm provenance. Every CI-published version of `@vivswan/github-settings-as-code` carries an npm provenance attestation naming this repository and workflow, which `npm audit signatures` checks in a project that installs it. A version without one, or whose attestation names another repository or workflow, is a vulnerability: every version on the registry is CI-published, and no exception exists.

## Verifying a release

What a `uses:` pin points at:

- The `vX.Y.Z` tags, the moving major, and `latest` point at packaged commits on the `build` branch: the source commit's tree without its workflows, plus the bundle built from that source by the workflow run named in its message. `main` carries no executable bundle, and the packaged commits carry no workflows (consumers run the action, never this repository's workflows).
- The `vX.Y.Z` tags, the moving major, and `latest` point at packaged commits: each the child of its source commit on `main`, carrying that tree plus the bundle and library built from it, minus `package.json`'s preparation scripts, by the workflow run its message names when CI minted it (a package minted by hand in the release recovery names none; see below). `main` carries no executable bundle.
- The tags up to v2.0.0 point at release commits on `main` from when `main` still committed the bundle. Nothing re-verifies them; the release-tags ruleset is what keeps them where they are.
- The release-tags ruleset freezes version tags for everything except deliberate repository-admin repair. The release workflow never moves a version tag; a rerun verifies the existing one byte-for-byte.
- npm publishes through trusted publishing (OIDC) from `ci.yml`. The package disallows tokens, so no registry token exists anywhere.
Expand Down
1,435 changes: 498 additions & 937 deletions .github/scripts/release-pipeline.ts

Large diffs are not rendered by default.

72 changes: 45 additions & 27 deletions .github/settings.local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,27 +35,26 @@ labels:
rulesets:
# NARROWS the release-please module's release-tags ruleset: the fleet
# declares it over `v*`, which would also freeze the moving major tag
# (v2) that update-release.yml force-moves with the Actions token on
# every release. A same-name ruleset merges key by key and an array
# replaces wholesale, so this entry swaps only the include list and
# inherits everything else (rules, enforcement, bypass) from the fleet.
# The vX.Y.Z tags stay immutable: each points at a commit carrying the
# built bundle that template-sync workflow pins and release artifacts
# reference - on the `build` branch (the release commit's tree without its
# workflows, plus the bundle) from now on; the tags cut before that branch
# existed point at main commits from when main still committed the bundle.
# The `latest` tag matches neither pattern; its own ruleset is below.
# (v2) that update-release.yml moves with the Actions token on every
# release. A same-name ruleset merges key by key and an array replaces
# wholesale, so this entry swaps only the include list and inherits
# everything else (rules, enforcement, bypass) from the fleet. The
# vX.Y.Z tags stay immutable: each points at a packaged commit, the
# release's merge commit's child carrying the built bundle and library
# (the tags cut before the pipeline packaged commits point at main
# commits from when main still committed the bundle). The `latest` and
# `build/*` tags match neither pattern; their rulesets are below.
- name: release-tags
conditions:
ref_name:
include:
- v*.*.*

# The moving major tags (v1, v2) move on purpose - every release in the
# line force-pushes them to its packaged commit - so no update rule, but
# they must never vanish: a deleted major bricks every @v2 consumer at
# once. fnmatch's * crosses dots, so v* also covers the vX.Y.Z tags;
# that overlap is a harmless union with release-tags above.
# line moves them to its packaged commit, forward only, under a lease -
# so no update rule, but they must never vanish: a deleted major bricks
# every @v2 consumer at once. fnmatch's * crosses dots, so v* also covers
# the vX.Y.Z tags; that overlap is a harmless union with release-tags above.
- name: major-release-tags
target: tag
enforcement: active
Expand All @@ -72,12 +71,11 @@ rulesets:
bypass_mode: always

# The moving `latest` tag: post-green.yml and the release hook point it at
# the `build` commit of the newest main source with a lease, so no update
# or non_fast_forward rule (either would refuse that move; the lease and
# the script's own checks keep it from going backward). Deletion is the one
# thing it must never suffer: a deleted latest bricks every @latest
# consumer at once. No bypass, like the build ruleset, so the apply heals
# an out-of-band bypass actor.
# the packaged commit of the newest main commit, forward only, under a
# lease, so no update or non_fast_forward rule (either would refuse that
# move). Deletion is the one thing it must never suffer: a deleted latest
# bricks every @latest consumer at once. No bypass, like the build-tags
# ruleset, so the apply heals an out-of-band bypass actor.
- name: latest-tag
target: tag
enforcement: active
Expand All @@ -90,18 +88,38 @@ rulesets:
- type: deletion
bypass_actors: []

# The `build` branch of packaged commits: post-green.yml appends a green
# main commit's packaged commit with a plain push when its token can (the
# release hook appends the release's when post-green did not), so build may
# only ever move forward - no force-push can rewrite the chain that
# `@build` consumers pin by sha and that the release hook walks to find
# a release's commit, and no deletion can remove it from under them.
# The packaged commits, one per green main commit: post-green.yml mints
# `build/<position>.<sha7>` once (its child, plus the built bundle and
# library) and prunes every tag beyond the ten newest, so a tag never
# moves in place (update, non_fast_forward) but may be deleted by the
# prune. Release tags and latest keep their own commits reachable; a
# build tag goes once ten newer commits are packaged, so a durable
# consumer pins a release.
# Declared with NO bypass, like the fleet's non-bypassable ruleset: an
# omitted key is invisible to drift detection, so the empty list is what
# lets the apply heal an out-of-band bypass actor.
- name: build-tags
target: tag
Comment thread
Vivswan marked this conversation as resolved.
enforcement: active
conditions:
ref_name:
include:
- build/*
exclude: []
rules:
- type: update
- type: non_fast_forward
bypass_actors: []

# The `build` branch of the retired chain: its rules are declared DISABLED
# rather than dropped, because the apply leaves an undeclared ruleset
# alone and the live deletion rule would refuse the owner's
# `git push origin --delete build`. The owner deletes the branch once
# every consumer has repinned (the build-branch break in docs/upgrading/v2-to-v3.md),
# then removes this entry together with the ruleset it disables.
- name: build
target: branch
enforcement: active
enforcement: disabled
conditions:
ref_name:
include:
Expand Down
23 changes: 12 additions & 11 deletions .github/workflows/post-green.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,30 @@ on:
type: string

jobs:
# No permissions key: the job inherits the caller's grant, which is enough because chain commits carry no .github/workflows/.
# GITHUB_TOKEN at contents: write, or a PAT -> pushes build and latest
# a read ceiling and no PAT -> warns and skips; the release hook publishes the release's chain commit and latest itself
# No permissions key: the job inherits the caller's grant, which is enough because a packaged commit changes no
# workflow file against its parent (the main commit), the diff GitHub judges a token's workflows grant on.
# GITHUB_TOKEN at contents: write, or a PAT -> pushes the build tag and latest
# a read ceiling and no PAT -> warns and skips; the release hook packages each release and moves latest itself
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.sha }}
# The script judges whether build's recorded sources lie on this commit's history; a shallow checkout cannot.
# The script names the tag by this commit's position on main and judges latest's source against its history; a shallow checkout can do neither.
fetch-depth: 0
token: ${{ secrets.REPO_PLATFORM_TOKEN || github.token }}
# A dry-run push asks origin for its receive-pack advertisement over the channel the real push uses, which a
# token without write access is refused; nothing is created. The target is a ref that never exists: HEAD is no
# descendant of build's tip, so a dry run against build itself would be rejected locally whatever the token.
# token without write access is refused; nothing is created. The target is a ref that never exists, so the dry
# run judges the token alone: against an existing tag git would refuse the non-fast-forward locally whatever the token.
#
# git's refusal is remote-supplied text, so it is printed inside a stop-commands fence keyed by a per-run token
# and never inside a workflow command.
# the runner acts on any line whose first non-blank text is "::" -> the fence disarms every such line
# only the fence's own token resumes command processing -> the text can neither forge a command nor swallow the error after it
# awk, not sed -> ends the last line even when git did not, so the fence stays alone
- name: Check the token can push to build
- name: Check the token can push
id: token
env:
PAT_SET: ${{ secrets.REPO_PLATFORM_TOKEN != '' }}
Expand All @@ -53,7 +54,7 @@ jobs:
exit 1
else
echo "::warning::this run's token cannot push (the caller grants contents: read);" \
"the build branch and the latest tag were not advanced here (the release hook advances them on each release)." \
"this commit was not packaged and the latest tag was not moved here (the release hook packages each release and moves latest itself)." \
"Raise the caller's ceiling to contents: write, or add a REPO_PLATFORM_TOKEN PAT secret" \
"with Contents (read and write) on this repository, to publish every green push to @latest."
echo "proceed=false" >> "$GITHUB_OUTPUT"
Expand All @@ -66,12 +67,12 @@ jobs:
run: |
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
- name: Package this commit under its build tag, prune the window, 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
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.
Expand Down Expand Up @@ -167,7 +168,7 @@ jobs:
exit 1 ;;
esac
# npm makes a publish readable asynchronously. This step holds the lane
# until the record shows the version (5 reads, 20 s apart), so the next
# 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,
Expand Down
Loading
Loading