From d5e3e160b46a6c28bce03ae415a9ccdbc486d17e Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Thu, 22 Oct 2026 22:00:10 +0200 Subject: [PATCH 1/2] feat: fall back to the shared zizmor policy in workflow-lint zizmor's default policy requires a SHA for privacybydesign/* too, so a new repository set up from the workflow templates failed workflow-lint on its @v1 references until it copied templates/zizmor.yml. Without a config of its own, a repository now gets the shared policy. --- .github/workflows/workflow-lint.yml | 15 ++++++++++++++- CHANGELOG.md | 4 ++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index 28ce89f..7f82e17 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -16,7 +16,7 @@ on: type: string default: 1.30.1 zizmor-config: - description: zizmor config file. Defaults to .github/zizmor.yml when it exists. + description: zizmor config file. Defaults to .github/zizmor.yml when it exists, else the shared policy (templates/zizmor.yml). type: string default: "" zizmor-min-severity: @@ -75,9 +75,22 @@ jobs: id: cfg env: CONFIG: ${{ inputs.zizmor-config }} + # Without a config of its own, a repository gets the shared policy, so + # the @v1 references from the workflow templates pass out of the box. run: | cfg="$CONFIG" [ -z "$cfg" ] && [ -f .github/zizmor.yml ] && cfg=.github/zizmor.yml + if [ -z "$cfg" ]; then + cfg="$RUNNER_TEMP/zizmor.yml" + cat > "$cfg" <<'EOF' + rules: + unpinned-uses: + config: + policies: + privacybydesign/*: ref-pin + "*": hash-pin + EOF + fi echo "config=$cfg" >> "$GITHUB_OUTPUT" - name: zizmor diff --git a/CHANGELOG.md b/CHANGELOG.md index d038ccc..76d7e03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v1.1.3 + +- `workflow-lint.yml`: a repository without its own `.github/zizmor.yml` now gets the shared policy (`privacybydesign/*` by tag, everything else by SHA) instead of zizmor's defaults, which reject `@v1`. Repositories set up from the workflow templates pass without copying `zizmor.yml`. + ## v1.1.2 - `docker-publish.yml`: the provenance attestation no longer tries to create an artifact storage record. That needed `artifact-metadata: write` from every caller and only produced a warning; the attestation itself is unchanged. From eee115e1d9969b8763402a8bbaa843980e835903 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Fri, 23 Oct 2026 13:53:19 +0200 Subject: [PATCH 2/2] feat: add caller templates for new repositories templates/workflows/ holds thin callers of the shared workflows (Status checks for Go and Node, Delivery, Image rescan, Terraform, releases), to copy into a new repository. They replace workflow templates in the org .github repository, which stays private and so could not serve the public repositories. Self CI lints them along with the workflows. --- .github/workflows/self-ci.yml | 4 +- CHANGELOG.md | 1 + README.md | 12 +++- templates/workflows/delivery.yml | 29 +++++++++ templates/workflows/ghcr-cleanup.yml | 22 +++++++ templates/workflows/go-release.yml | 28 ++++++++ templates/workflows/image-rescan.yml | 20 ++++++ templates/workflows/npm-publish.yml | 21 ++++++ templates/workflows/pr-title.yml | 13 ++++ templates/workflows/release-please.yml | 18 ++++++ templates/workflows/scorecard.yml | 37 +++++++++++ templates/workflows/status-checks-go.yml | 73 +++++++++++++++++++++ templates/workflows/status-checks-node.yml | 74 ++++++++++++++++++++++ templates/workflows/terraform.yml | 68 ++++++++++++++++++++ 14 files changed, 417 insertions(+), 3 deletions(-) create mode 100644 templates/workflows/delivery.yml create mode 100644 templates/workflows/ghcr-cleanup.yml create mode 100644 templates/workflows/go-release.yml create mode 100644 templates/workflows/image-rescan.yml create mode 100644 templates/workflows/npm-publish.yml create mode 100644 templates/workflows/pr-title.yml create mode 100644 templates/workflows/release-please.yml create mode 100644 templates/workflows/scorecard.yml create mode 100644 templates/workflows/status-checks-go.yml create mode 100644 templates/workflows/status-checks-node.yml create mode 100644 templates/workflows/terraform.yml diff --git a/.github/workflows/self-ci.yml b/.github/workflows/self-ci.yml index a6eb19d..6480125 100644 --- a/.github/workflows/self-ci.yml +++ b/.github/workflows/self-ci.yml @@ -17,8 +17,8 @@ jobs: contents: read with: zizmor-config: zizmor.yml - paths: .github - actionlint-paths: .github/workflows + paths: .github templates/workflows + actionlint-paths: .github/workflows templates/workflows passed: name: Status checks passed diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d7e03..0f1086c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## v1.1.3 +- `templates/workflows/`: caller templates for new repositories (Status checks for Go and Node, Delivery, Image rescan, Terraform, releases and more), linted by Self CI. They replace the workflow templates for the org `.github` repository, which public repositories could not use while that repository is private. - `workflow-lint.yml`: a repository without its own `.github/zizmor.yml` now gets the shared policy (`privacybydesign/*` by tag, everything else by SHA) instead of zizmor's defaults, which reject `@v1`. Repositories set up from the workflow templates pass without copying `zizmor.yml`. ## v1.1.2 diff --git a/README.md b/README.md index ab99bca..ddef9cc 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,17 @@ jobs: contents: read ``` -Copy [`templates/dependabot.yml`](templates/dependabot.yml) and [`templates/zizmor.yml`](templates/zizmor.yml) to your `.github/` directory: Dependabot keeps your third-party actions pinned and current, and zizmor allows `@v1` for these workflows but requires a commit SHA for every other action. +Copy [`templates/dependabot.yml`](templates/dependabot.yml) to your `.github/` directory: Dependabot keeps your third-party actions pinned and current. workflow-lint allows `@v1` for these workflows and requires a commit SHA for every other action; add [`templates/zizmor.yml`](templates/zizmor.yml) as `.github/zizmor.yml` only if you need to change that policy. + +### Starting a new repository + +In the privacybydesign organization, a pull request can only merge once a check named `Status checks passed` succeeds. The templates in [`templates/workflows/`](templates/workflows) give you that check: + +1. Copy `status-checks-go.yml` or `status-checks-node.yml` to `.github/workflows/status-checks.yml` and adjust the inputs (working directory, Dockerfile, languages). It ends in the `Status checks passed` job. +2. Copy `templates/dependabot.yml` to `.github/dependabot.yml` and remove the ecosystems you don't use. +3. If the repository publishes a container image, also add `delivery.yml` and `image-rescan.yml`. + +Open a pull request with these files. It runs the new status checks itself, so it can merge once they pass. Other templates: `release-please.yml`, `go-release.yml`, `npm-publish.yml`, `pr-title.yml`, `terraform.yml`, `ghcr-cleanup.yml` and `scorecard.yml`. The inputs of each workflow are documented in its file, under `on.workflow_call.inputs`. A reusable workflow gets no more permissions than the calling job grants, so grant the ones its file lists. diff --git a/templates/workflows/delivery.yml b/templates/workflows/delivery.yml new file mode 100644 index 0000000..f9d6e53 --- /dev/null +++ b/templates/workflows/delivery.yml @@ -0,0 +1,29 @@ +# Build, scan (Anchore) and publish the container image to ghcr.io/${{ github.repository }}. +# push to the default branch -> :edge release published / v* tag -> : manual run -> : +# PR builds are scanned in "Status checks" (push-image: false); published +# images are re-scanned weekly by "Image rescan". +name: Delivery + +on: + push: + branches: [main] # your default branch + release: + types: [published] + workflow_dispatch: + +permissions: {} + +jobs: + docker: + uses: privacybydesign/github-workflows/.github/workflows/docker-publish.yml@v1 + permissions: + contents: read + packages: write + security-events: write + id-token: write + attestations: write + with: + platforms: linux/amd64,linux/arm64 + # tag-latest: true + # tag-semver-rollups: true + # cosign: true # keyless signature; recorded in the public Rekor log diff --git a/templates/workflows/ghcr-cleanup.yml b/templates/workflows/ghcr-cleanup.yml new file mode 100644 index 0000000..58036f4 --- /dev/null +++ b/templates/workflows/ghcr-cleanup.yml @@ -0,0 +1,22 @@ +# Weekly GHCR retention: delete unreferenced untagged versions (old edge builds). +# Starts as a dry run: check the job summary, then set dry-run: false. +name: GHCR clean-up + +on: + schedule: + - cron: "41 3 * * 0" # Sundays + workflow_dispatch: + +permissions: {} + +jobs: + cleanup: + uses: privacybydesign/github-workflows/.github/workflows/ghcr-cleanup.yml@v1 + permissions: + contents: read + packages: write + with: + dry-run: true + min-age-days: 14 + # delete-tags-regex: 'feature-.*|fix-.*' # branch tags from manual runs; never match edge or versions + # tag-max-age-days: 90 diff --git a/templates/workflows/go-release.yml b/templates/workflows/go-release.yml new file mode 100644 index 0000000..c2a36a6 --- /dev/null +++ b/templates/workflows/go-release.yml @@ -0,0 +1,28 @@ +# Build Go binaries for a tag, attach them (+ SBOM, checksums, attestations) +# to a DRAFT release and publish it. Works with immutable releases. +# With release-please: set "draft": true and "force-tag-creation": true. +name: Release binaries + +on: + push: + tags: ["v*"] + pull_request: # smoke-test the build matrix, nothing is uploaded + +permissions: {} + +jobs: + binaries: + uses: privacybydesign/github-workflows/.github/workflows/go-release.yml@v1 + permissions: + contents: write + id-token: write + attestations: write + with: + binary-name: CHANGE_ME + main-package: ./cmd/CHANGE_ME + # version-variable: main.version + # require-changelog-entry: true + secrets: + # Publishing with the App makes 'release: published' (Delivery) run. + RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/templates/workflows/image-rescan.yml b/templates/workflows/image-rescan.yml new file mode 100644 index 0000000..31b7137 --- /dev/null +++ b/templates/workflows/image-rescan.yml @@ -0,0 +1,20 @@ +# Weekly re-scan of the PUBLISHED images (all platforms) for new CVEs. +name: Image rescan + +on: + schedule: + - cron: "23 6 * * 1" # Mondays + workflow_dispatch: + +permissions: {} + +jobs: + rescan: + uses: privacybydesign/github-workflows/.github/workflows/image-rescan.yml@v1 + permissions: + contents: read + packages: read + security-events: write + with: + tags: edge latest-release + severity-cutoff: high diff --git a/templates/workflows/npm-publish.yml b/templates/workflows/npm-publish.yml new file mode 100644 index 0000000..f44dbd4 --- /dev/null +++ b/templates/workflows/npm-publish.yml @@ -0,0 +1,21 @@ +# Publish to npm when a GitHub release is published. +# One-time setup on npmjs.com: package > Settings > Trusted Publisher > +# GitHub Actions, owner privacybydesign, this repository, workflow file = THIS file's name. +name: Publish to npm + +on: + release: + types: [published] + +permissions: {} + +jobs: + npm: + uses: privacybydesign/github-workflows/.github/workflows/npm-publish.yml@v1 + permissions: + contents: write + issues: write + pull-requests: write + id-token: write + # with: + # release-command: npm run release # semantic-release instead of tag-driven diff --git a/templates/workflows/pr-title.yml b/templates/workflows/pr-title.yml new file mode 100644 index 0000000..556f6d2 --- /dev/null +++ b/templates/workflows/pr-title.yml @@ -0,0 +1,13 @@ +name: PR title + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: {} + +jobs: + pr-title: + uses: privacybydesign/github-workflows/.github/workflows/pr-title.yml@v1 + permissions: + pull-requests: read diff --git a/templates/workflows/release-please.yml b/templates/workflows/release-please.yml new file mode 100644 index 0000000..f547682 --- /dev/null +++ b/templates/workflows/release-please.yml @@ -0,0 +1,18 @@ +name: Release please + +on: + push: + branches: [main] # your default branch + +permissions: {} + +jobs: + release-please: + uses: privacybydesign/github-workflows/.github/workflows/release-please.yml@v1 + permissions: + contents: write + pull-requests: write + issues: write + secrets: + RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/templates/workflows/scorecard.yml b/templates/workflows/scorecard.yml new file mode 100644 index 0000000..75950c7 --- /dev/null +++ b/templates/workflows/scorecard.yml @@ -0,0 +1,37 @@ +# OpenSSF Scorecard (public repositories). Kept standalone on purpose: +# Scorecard only publishes results from a workflow that contains just this job. +name: Scorecard + +on: + branch_protection_rule: + schedule: + - cron: "37 5 * * 1" + push: + branches: [main] # your default branch + workflow_dispatch: + +permissions: {} + +jobs: + analysis: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + actions: read + security-events: write + id-token: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + with: + sarif_file: results.sarif diff --git a/templates/workflows/status-checks-go.yml b/templates/workflows/status-checks-go.yml new file mode 100644 index 0000000..dfa3ea7 --- /dev/null +++ b/templates/workflows/status-checks-go.yml @@ -0,0 +1,73 @@ +# Status checks for a Go service/library (optionally with a Dockerfile). +name: Status checks + +on: + push: + branches: [main] # your default branch + pull_request: + merge_group: + schedule: + - cron: "17 6 * * 1" # Mondays: CodeQL with fresh queries + workflow_dispatch: + +permissions: {} + +jobs: + go: + uses: privacybydesign/github-workflows/.github/workflows/go-checks.yml@v1 + permissions: + contents: read + with: + working-directory: . + # test-dockerfile: Dockerfile.test + # apt-packages: libpcsclite-dev + + # Build + scan the image on PRs without pushing. Remove if there is no Dockerfile. + docker: + if: github.event_name == 'pull_request' || github.event_name == 'merge_group' + uses: privacybydesign/github-workflows/.github/workflows/docker-publish.yml@v1 + permissions: + contents: read + packages: write + security-events: write + id-token: write + attestations: write + with: + push-image: false + + codeql: + uses: privacybydesign/github-workflows/.github/workflows/codeql.yml@v1 + permissions: + contents: read + security-events: write + actions: read + with: + languages: '["go", "actions"]' + + dependency-review: + uses: privacybydesign/github-workflows/.github/workflows/dependency-review.yml@v1 + permissions: + contents: read + pull-requests: write + + workflow-lint: + uses: privacybydesign/github-workflows/.github/workflows/workflow-lint.yml@v1 + permissions: + contents: read + + # Single required check for rulesets/branch protection: job names inside this + # workflow can change without touching the ruleset. + passed: + name: Status checks passed + if: always() + needs: [go, docker, codeql, dependency-review, workflow-lint] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: All required jobs succeeded + env: + RESULTS: ${{ toJSON(needs.*.result) }} + run: | + echo "$RESULTS" + if grep -qE '"(failure|cancelled)"' <<< "$RESULTS"; then exit 1; fi diff --git a/templates/workflows/status-checks-node.yml b/templates/workflows/status-checks-node.yml new file mode 100644 index 0000000..13a50b9 --- /dev/null +++ b/templates/workflows/status-checks-node.yml @@ -0,0 +1,74 @@ +# Status checks for a Node.js / TypeScript project (optionally with a Dockerfile). +name: Status checks + +on: + push: + branches: [main] # your default branch + pull_request: + merge_group: + schedule: + - cron: "17 6 * * 1" # Mondays: CodeQL with fresh queries + workflow_dispatch: + +permissions: {} + +jobs: + node: + uses: privacybydesign/github-workflows/.github/workflows/node-checks.yml@v1 + permissions: + contents: read + with: + working-directory: . + scripts: lint test build + ignore-scripts: true # block dependency install scripts + # allowed-scripts: esbuild + + # Build + scan the image on PRs without pushing. Remove if there is no Dockerfile. + docker: + if: github.event_name == 'pull_request' || github.event_name == 'merge_group' + uses: privacybydesign/github-workflows/.github/workflows/docker-publish.yml@v1 + permissions: + contents: read + packages: write + security-events: write + id-token: write + attestations: write + with: + push-image: false + + codeql: + uses: privacybydesign/github-workflows/.github/workflows/codeql.yml@v1 + permissions: + contents: read + security-events: write + actions: read + with: + languages: '["javascript-typescript", "actions"]' + + dependency-review: + uses: privacybydesign/github-workflows/.github/workflows/dependency-review.yml@v1 + permissions: + contents: read + pull-requests: write + + workflow-lint: + uses: privacybydesign/github-workflows/.github/workflows/workflow-lint.yml@v1 + permissions: + contents: read + + # Single required check for rulesets/branch protection: job names inside this + # workflow can change without touching the ruleset. + passed: + name: Status checks passed + if: always() + needs: [node, docker, codeql, dependency-review, workflow-lint] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: All required jobs succeeded + env: + RESULTS: ${{ toJSON(needs.*.result) }} + run: | + echo "$RESULTS" + if grep -qE '"(failure|cancelled)"' <<< "$RESULTS"; then exit 1; fi diff --git a/templates/workflows/terraform.yml b/templates/workflows/terraform.yml new file mode 100644 index 0000000..af6bd4a --- /dev/null +++ b/templates/workflows/terraform.yml @@ -0,0 +1,68 @@ +# Terraform: checks + plan on PRs, gated apply on the default branch, +# drift detection on the weekly schedule. Also the repo's "Status checks". +name: Status checks + +on: + push: + branches: [main] # your default branch + pull_request: + merge_group: + schedule: + - cron: "17 6 * * 1" # Mondays: drift detection + workflow_dispatch: + +permissions: {} + +jobs: + terraform: + strategy: + fail-fast: false + matrix: + dir: [terraform] # one entry per root module + uses: privacybydesign/github-workflows/.github/workflows/terraform.yml@v1 + permissions: + contents: read + pull-requests: write + id-token: write + with: + working-directory: ${{ matrix.dir }} + apply: true + environment: production + checkov: warn + secrets: + TF_ENV: ${{ secrets.TF_ENV }} + + codeql: + uses: privacybydesign/github-workflows/.github/workflows/codeql.yml@v1 + permissions: + contents: read + security-events: write + actions: read + + dependency-review: + uses: privacybydesign/github-workflows/.github/workflows/dependency-review.yml@v1 + permissions: + contents: read + pull-requests: write + + workflow-lint: + uses: privacybydesign/github-workflows/.github/workflows/workflow-lint.yml@v1 + permissions: + contents: read + + # Single required check for rulesets/branch protection: job names inside this + # workflow can change without touching the ruleset. + passed: + name: Status checks passed + if: always() + needs: [terraform, codeql, dependency-review, workflow-lint] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: All required jobs succeeded + env: + RESULTS: ${{ toJSON(needs.*.result) }} + run: | + echo "$RESULTS" + if grep -qE '"(failure|cancelled)"' <<< "$RESULTS"; then exit 1; fi