From bc181ead0178c72d38d0d6172098e485aafc77c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:39:07 +0200 Subject: [PATCH] test(ci): guard the workflow action pins against regressing to tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The org Actions policy sets `allowed_actions: selected` together with `sha_pinning_required: true` (owncloud/admin:actions-allowlist.yml), so an action referenced by a movable tag is rejected even though `actions/*` is allowed as GitHub-owned. GitHub enforces that *before* it creates any job: the run ends as `startup_failure` with no jobs, no logs and no check-runs at all, so the required `build` check never reports and every PR silently becomes unmergeable -- the outage #94 fixed. The only trace is an annotation on the run page, which the REST API does not expose, so a repeat of #94 is disproportionately expensive to diagnose from the Actions UI. Assert the invariant where it is cheap to see instead -- `npm test`: - every `uses:` is pinned to a full-length (40 hex) commit SHA - every pin carries a `# vX.Y.Z` comment, so the SHA stays reviewable and Dependabot knows which version it currently represents - the workflows yield at least one `uses:` ref, so a parse that matches nothing cannot make the two assertions above vacuously pass Local (`./`) and container (`docker://`) refs are not action repositories and are out of scope for the policy, so they are skipped. Co-Authored-By: Claude Opus 5 Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- test/workflow-action-pins.test.js | 67 +++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 test/workflow-action-pins.test.js diff --git a/test/workflow-action-pins.test.js b/test/workflow-action-pins.test.js new file mode 100644 index 0000000..b42410e --- /dev/null +++ b/test/workflow-action-pins.test.js @@ -0,0 +1,67 @@ +'use strict' + +// Guards the `uses:` refs in .github/workflows/ against the owncloud org's +// Actions policy (owncloud/admin: actions-allowlist.yml). That policy sets +// `allowed_actions: selected` together with `sha_pinning_required: true`, so an +// action referenced by a movable tag -- `actions/checkout@v7` -- is rejected +// even though `actions/*` is allowed as GitHub-owned. GitHub enforces this +// BEFORE any job is created: the whole run ends as `startup_failure` with no +// jobs and no logs, which also means the required `build` check never reports. +// +// Keeping the assertion here makes the breakage visible from `npm test` instead +// of only from a push that nobody can debug from the Actions UI. + +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const WORKFLOWS = path.join(__dirname, '..', '.github', 'workflows') + +// `uses: owner/repo[/subdir]@ref` plus the trailing `# vX.Y.Z` comment, if any. +// Local (`./…`) and container (`docker://…`) refs are not action repositories +// and are out of scope for the pinning policy. +const USES = /^\s*(?:-\s+)?uses:\s*['"]?([^'"\s#]+)['"]?\s*(?:#\s*(.*))?$/ + +function actionRefs () { + const refs = [] + for (const file of fs.readdirSync(WORKFLOWS)) { + if (!/\.ya?ml$/.test(file)) continue + const lines = fs.readFileSync(path.join(WORKFLOWS, file), 'utf8').split('\n') + lines.forEach((line, i) => { + const m = line.match(USES) + if (!m) return + const [, ref, comment] = m + if (ref.startsWith('./') || ref.startsWith('docker://')) return + refs.push({ where: `${file}:${i + 1}`, ref, comment: comment || '' }) + }) + } + return refs +} + +const REFS = actionRefs() + +test('the workflows reference at least one action', () => { + // A silent zero-match parse would make every assertion below vacuously pass. + assert.ok(REFS.length > 0, `no \`uses:\` refs found under ${WORKFLOWS}`) +}) + +test('every action is pinned to a full-length commit SHA', () => { + const unpinned = REFS + .filter(({ ref }) => !/@[0-9a-f]{40}$/.test(ref)) + .map(({ where, ref }) => `${where}: ${ref}`) + assert.deepEqual( + unpinned, + [], + 'movable refs are rejected by the org policy (sha_pinning_required) and fail the run at startup' + ) +}) + +test('every pinned action records its human-readable version in a comment', () => { + // The SHA alone is unreviewable, and Dependabot needs the `# vX.Y.Z` marker + // to know which version a pin currently represents. + const undocumented = REFS + .filter(({ comment }) => !/^v\d+(\.\d+)*/.test(comment.trim())) + .map(({ where, ref }) => `${where}: ${ref}`) + assert.deepEqual(undocumented, [], 'pinned actions missing a `# vX.Y.Z` version comment') +})