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
16 changes: 8 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ jobs:
# the newest makes the floor a claim nobody checks.
node: [22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
# test/dogfood.test.ts validates this repository's own commit history.
# A shallow clone would truncate it, and the suite is written to fail
# loudly rather than silently check nothing -- but there is no reason
# to make it do that on every run.
fetch-depth: 0

- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: ${{ matrix.node }}
cache: npm
Expand Down Expand Up @@ -189,10 +189,10 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
Expand Down Expand Up @@ -230,10 +230,10 @@ jobs:
install-script:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
Expand Down Expand Up @@ -353,8 +353,8 @@ jobs:
# the runner's six-hour default and prints nothing.
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22

Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/demo-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
# The action refuses a shallow clone rather than lint whatever
# subset of history the default depth happened to bring.
Expand All @@ -43,7 +43,7 @@ jobs:
echo "origin carries no refs/notes/commitlore yet -- nothing to mirror"
fi

- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
Expand All @@ -67,7 +67,7 @@ jobs:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0

Expand All @@ -79,7 +79,7 @@ jobs:
echo "origin carries no refs/notes/commitlore yet -- nothing to mirror"
fi

- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/demo-preserve.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
# `refs/pull/<n>/merge` is gone once the pull request is closed, so
# the default ref for this event does not resolve. The merge commit
Expand Down Expand Up @@ -62,7 +62,7 @@ jobs:
git fetch --no-tags --force origin \
'+refs/pull/${{ github.event.pull_request.number }}/head:refs/commitlore/pr-head'

- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
Expand Down
22 changes: 11 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ on:

# Read for the workflow; the ability to create a release belongs to the one job
# that creates one. Granting `contents: write` here hands it to the gates too,
# and the gates run `actions/checkout@v4` and `actions/setup-node@v4` — mutable
# and the gates run `actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4` and `actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4` — mutable
# major tags whose contents can change under the same reference. Anything
# executing inside a gate with a release-write token could create the release
# before that gate, or any other, had finished, which would make the ordering
Expand Down Expand Up @@ -43,11 +43,11 @@ jobs:
outputs:
commit: ${{ steps.ancestry.outputs.sha }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.sha }}
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- id: ancestry
Expand All @@ -69,10 +69,10 @@ jobs:
needs: release-target
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ needs.release-target.outputs.commit }}
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
Expand All @@ -90,11 +90,11 @@ jobs:
needs: release-target
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ needs.release-target.outputs.commit }}
fetch-depth: 1
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- name: Every required CI check succeeded at the tagged commit
Expand All @@ -113,11 +113,11 @@ jobs:
env:
RELEASE_COMMIT: ${{ needs.release-target.outputs.commit }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ needs.release-target.outputs.commit }}
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22

Expand Down Expand Up @@ -269,10 +269,10 @@ jobs:
env:
RELEASE_COMMIT: ${{ needs.release-target.outputs.commit }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ needs.release-target.outputs.commit }}
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22

Expand Down
28 changes: 26 additions & 2 deletions test/release-tag-binding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
*/

import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand All @@ -29,6 +29,7 @@ const TAG_BINDING = join(REPO_ROOT, 'scripts', 'check-tag-binding.mjs');
const RELEASE_TARGET = join(REPO_ROOT, 'scripts', 'check-release-target.mjs');
const EXACT_HEAD_CI = join(REPO_ROOT, 'scripts', 'check-exact-head-ci.mjs');
const RELEASE_WORKFLOW = join(REPO_ROOT, '.github', 'workflows', 'release.yml');
const WORKFLOW_DIR = join(REPO_ROOT, '.github', 'workflows');

const TAG = 'v9.9.9';
const REF = `refs/tags/${TAG}`;
Expand Down Expand Up @@ -224,7 +225,11 @@ describe('#499 the published tag is the commit the gates qualified', () => {
expect(result.stderr).toContain(`now resolves to ${second}`);
});

it('keeps the canonical commit at the event sha when the tag moves before the resolver runs', () => {
// Paired with the workflow-shape case asserting the resolver is handed
// `$GITHUB_SHA`: this one shows the script answers for the commit it is
// given, not for whatever the tag names now. Neither half claims the other's
// property, and only together do they close the window.
it('answers for the commit it is handed, not the one the tag now names', () => {
// The window this closes: a move landing between the push and the first
// job. Resolving the tag name here would hand every downstream gate the
// attacker's commit and they would all agree, greenly, on the wrong one.
Expand Down Expand Up @@ -495,6 +500,25 @@ describe('#499 every release boundary consumes one canonical sha', () => {
expect(parsed.jobs['publish']?.permissions?.['contents']).toBe('write');
});

// Scoping the release-write grant removed the worst consequence of a changed
// action; it did not stop one from running. A digest names one immutable
// tree, so an action cannot become something else under the same reference.
it('references every action by digest rather than a movable tag', () => {
const unpinned: string[] = [];
for (const file of readdirSync(WORKFLOW_DIR).filter((name) => name.endsWith('.yml'))) {
const raw = readFileSync(join(WORKFLOW_DIR, file), 'utf8');
for (const line of raw.split('\n')) {
const match = /uses:\s*(\S+)/.exec(line);
if (match === null) continue;
const ref = match[1] ?? '';
if (ref.startsWith('./')) continue;
if (!/@[0-9a-f]{40}$/.test(ref)) unpinned.push(`${file}: ${ref}`);
}
}

expect(unpinned).toEqual([]);
});

it('grants no privilege for an action the workflow does not contain', () => {
const raw = readFileSync(RELEASE_WORKFLOW, 'utf8');
const parsed = load(raw) as { permissions?: Record<string, string> };
Expand Down
Loading