Skip to content
Draft
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
33 changes: 33 additions & 0 deletions .github/workflows/workflow-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Shared Workflow Tests

on:
push:
branches: [main]
pull_request:

permissions: {}

jobs:
test:
name: Test shared workflow contract
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"

- name: Test reporting modes and permissions
run: node --test tests/zizmor-workflow.test.mjs

- name: Validate workflows and caller fixtures
uses: docker://rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667
with:
args: .github/workflows/zizmor.yml .github/workflows/workflow-tests.yml tests/fixtures/sarif-caller.yml tests/fixtures/annotations-caller.yml
34 changes: 34 additions & 0 deletions .github/workflows/zizmor.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: GitHub Actions Security Analysis

on:
workflow_call:
inputs:
advanced-security:
description: Upload SARIF to code scanning. Disable for annotation-only reporting.
required: false
type: boolean
default: true

# Intentionally inherit the caller's explicitly scoped job permissions.
# SARIF needs contents: read, actions: read, and security-events: write;
# annotation-only callers need only contents: read. This workflow cannot
# elevate the caller's token permissions. See README.md for caller examples.

jobs:
zizmor:
name: Run zizmor 🌈
runs-on: ubuntu-latest
env:
# Fork PRs always use annotations without attempting a SARIF upload.
ZIZMOR_ADVANCED_SECURITY: ${{ inputs.advanced-security && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Run zizmor 🌈
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
with:
advanced-security: ${{ env.ZIZMOR_ADVANCED_SECURITY }}
annotations: ${{ env.ZIZMOR_ADVANCED_SECURITY != 'true' }}
95 changes: 95 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,101 @@ A repository inherits the baseline by extending the shared preset, then adds its

`default.json`, `renovate-config.json`, and any other preset files are validated with `renovate-config-validator --strict` by [`.github/workflows/renovate-config-validator.yml`](.github/workflows/renovate-config-validator.yml). Consumer repositories call the same reusable workflow to validate their own `renovate.json`.

## 🛡️ Shared GitHub Actions security scanning

[`.github/workflows/zizmor.yml`](.github/workflows/zizmor.yml) is a reusable
workflow that checks the **calling repository's** workflows and actions with
zizmor. Checkout does not persist credentials, repository-local zizmor
configuration is discovered normally, and no application scripts are executed.

### Calling the workflow

Add this caller as `.github/workflows/zizmor.yml`. Replace the placeholder with
the **full SHA of a merged provider commit** before using it:

```yaml
name: GitHub Actions Security Analysis

on:
push:
branches: [main]
pull_request:

permissions: {}

jobs:
zizmor:
permissions:
contents: read
actions: read
security-events: write
uses: gitify-app/.github/.github/workflows/zizmor.yml@REPLACE_WITH_FULL_COMMIT_SHA
```

Do not filter pull requests by path: even documentation-only PRs should receive
the check. No inherited secrets or personal token is required.

### Reporting and token permissions

- `advanced-security` is a boolean input, defaulting to `true`. For supported
same-repository PRs and pushes, findings are uploaded to the **caller's** code
scanning interface with category `zizmor`. Upload failures remain visible.
- Fork PRs always use annotations instead of uploading SARIF. They run under
ordinary `pull_request` permissions, without privileged tokens or
`pull_request_target`. GitHub can require maintainer approval before a fork's
workflow runs.
- Set `advanced-security: false` when code-scanning uploads are unavailable.
Annotation mode retains zizmor's failure exit status for findings. SARIF mode
retains code-scanning gating: a successful scanner job alone does not mean
there are no findings.

The reusable workflow **intentionally inherits the calling job's permissions**.
GitHub Actions cannot select permissions from an input, and a reusable workflow
cannot elevate the caller's token. Keep `permissions: {}` at the caller's
workflow level and grant only the permissions needed by its calling job.
For annotation-only reporting, that job can use just read access:

```yaml
jobs:
zizmor:
permissions:
contents: read
uses: gitify-app/.github/.github/workflows/zizmor.yml@REPLACE_WITH_FULL_COMMIT_SHA
with:
advanced-security: false
```

### Validation and rollout

Regression tests use Node.js 24+ and no package dependencies:

```sh
node --test tests/zizmor-workflow.test.mjs
actionlint .github/workflows/zizmor.yml .github/workflows/workflow-tests.yml tests/fixtures/*-caller.yml
zizmor --no-online-audits .github/workflows/zizmor.yml tests/fixtures/*-caller.yml
```

The tests evaluate the workflow's boolean reporting expressions for pushes,
same-repository PRs, and fork PRs, and check the provider/caller permission
contract. They are not a substitute for live GitHub upload and fork-PR checks.

Merge the provider first, then open separate caller migrations in website,
GNOME, Gitify, and KDE pinned to its merged SHA. Add this repository's own
SHA-pinned self-scan caller in a follow-up change. Keep these changes separate
from Renovate config work.

Reusable jobs can change the displayed check name (for example,
`zizmor / Run zizmor 🌈`). Review each repository's required checks before
merging; no current required check in the initial rollout references the
duplicated zizmor job. Preserve category `zizmor`, and verify GitHub's analysis
key/check display after migration. Gitify and website have other required
checks, which are unaffected by this migration.

Update provider SHA references through normal dependency PRs; Renovate's
GitHub Actions manager recognizes reusable workflow dependencies. For rollback,
restore the previous local workflow, or revert the new caller in KDE. Keep
provider commits available while consumers still reference them.

## 📄 License

Released under the [MIT License](LICENSE).
Expand Down
18 changes: 18 additions & 0 deletions tests/fixtures/annotations-caller.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Annotation caller fixture

on:
push:
branches: [main]
pull_request:

permissions: {}

jobs:
zizmor:
permissions:
contents: read
# Keep this local lint fixture compatible with actionlint 1.7.12, which
# does not support $/ self-repository syntax. Production callers use SHAs.
uses: ./.github/workflows/zizmor.yml # zizmor: ignore[self-repository]
with:
advanced-security: false
18 changes: 18 additions & 0 deletions tests/fixtures/sarif-caller.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: SARIF caller fixture

on:
push:
branches: [main]
pull_request:

permissions: {}

jobs:
zizmor:
permissions:
contents: read
actions: read
security-events: write
# Keep this local lint fixture compatible with actionlint 1.7.12, which
# does not support $/ self-repository syntax. Production callers use SHAs.
uses: ./.github/workflows/zizmor.yml # zizmor: ignore[self-repository]
102 changes: 102 additions & 0 deletions tests/zizmor-workflow.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { runInNewContext } from 'node:vm';

const workflow = readFileSync(
new URL('../.github/workflows/zizmor.yml', import.meta.url),
'utf8',
);

function expressionFor(key) {
const match = workflow.match(
new RegExp(`^\\s+${key}: \\$\\{\\{ (.+) \\}\\}$`, 'm'),
);
assert.ok(match, `Missing expression for ${key}`);
return match[1];
}

// These expressions use only boolean inputs, string comparisons, and &&/||,
// whose behavior matches JavaScript for the concrete values in these tests.
// Evaluate the actual workflow expression, not a separately copied condition.
function reportingMode(advancedSecurity, eventName, headRepository) {
const context = {
inputs: { 'advanced-security': advancedSecurity },
github: {
event_name: eventName,
repository: 'gitify-app/website',
event: eventName === 'pull_request'
? { pull_request: { head: { repo: { full_name: headRepository } } } }
: {},
},
};
const upload = runInNewContext(
expressionFor('ZIZMOR_ADVANCED_SECURITY').replaceAll(
'inputs.advanced-security',
'inputs["advanced-security"]',
),
context,
{ timeout: 1000 },
);
context.env = { ZIZMOR_ADVANCED_SECURITY: String(upload) };
return {
upload: runInNewContext(expressionFor('advanced-security'), context),
annotations: runInNewContext(expressionFor('annotations'), context),
};
}

for (const [name, enabled, event, head, upload] of [
['push with uploads enabled', true, 'push', undefined, true],
['same-repository PR with uploads enabled', true, 'pull_request', 'gitify-app/website', true],
['fork PR with uploads enabled', true, 'pull_request', 'contributor/website', false],
['push with uploads disabled', false, 'push', undefined, false],
['same-repository PR with uploads disabled', false, 'pull_request', 'gitify-app/website', false],
['fork PR with uploads disabled', false, 'pull_request', 'contributor/website', false],
['PR with unavailable head repository', true, 'pull_request', '', false],
]) {
test(name, () => {
assert.deepEqual(reportingMode(enabled, event, head), {
upload: String(upload),
annotations: !upload,
});
});
}

test('provider exposes a boolean upload input, enabled by default', () => {
assert.match(workflow, /workflow_call:/);
assert.match(workflow, /advanced-security:\n(?:[^\n]*\n)*?\s+type: boolean\n\s+default: true/);
});

test('provider inherits caller-scoped permissions without requesting elevation', () => {
assert.doesNotMatch(workflow, /^\s*permissions:/m);
assert.doesNotMatch(workflow, /^\s*secrets:/m);
assert.doesNotMatch(workflow, /pull_request_target:/);
});

test('provider pins actions and does not persist checkout credentials', () => {
const actions = [...workflow.matchAll(/^\s+uses: (.+)$/gm)];
assert.equal(actions.length, 2);
for (const [, action] of actions) {
assert.match(action, /^[\w./-]+@[a-f0-9]{40}(?:\s+#.*)?$/);
}
assert.match(workflow, /persist-credentials: false/);
});

test('provider only scans: no consumer scripts, custom config, or swallowed errors', () => {
assert.doesNotMatch(workflow, /^\s+(?:run|config|continue-on-error):/m);
});

test('SARIF caller fixture grants exactly the documented permissions', () => {
const fixture = readFileSync(new URL('./fixtures/sarif-caller.yml', import.meta.url), 'utf8');
assert.match(fixture, /^permissions: \{\}$/m);
assert.match(fixture, / permissions:\n contents: read\n actions: read\n security-events: write\n/);
assert.match(fixture, /uses: \.\/\.github\/workflows\/zizmor.yml/);
});

test('annotation caller fixture needs only read access and disables uploads', () => {
const fixture = readFileSync(new URL('./fixtures/annotations-caller.yml', import.meta.url), 'utf8');
assert.match(fixture, /^permissions: \{\}$/m);
assert.match(fixture, / permissions:\n contents: read\n(?: #.*\n)* uses:/);
assert.doesNotMatch(fixture, /security-events:|actions: read/);
assert.match(fixture, /advanced-security: false/);
});
Loading