Skip to content

Repository files navigation

github-workflows

Reusable GitHub Actions workflows for Bitwise Media Group repositories. Each consuming repo keeps a thin caller workflow that owns its triggers and calls one of these by uses:, so CI, CodeQL, release, and the fast-forward /merge flow live in one place instead of being copy-pasted per repo.

Copy a caller from examples/ into your repo's .github/workflows/, then pin it (see Pinning).

Catalog

All reusable workflows live in .github/workflows/ and are workflow_call-only. Every external action is pinned to a full commit SHA; the org Renovate bot (bitwise-media-group/renovate-config) keeps the pins fresh.

Workflow Platform What it does
ci.yaml any canonical mise tasks (lint/build/test) per job, committed dist/ verified, every toolchain from the mise pins, Codecov upload
security.yaml any CodeQL over actions + go (autobuild, Go from the mise pins) + javascript-typescript, language matrix by detection
release.yaml any release-please (two-pass) → GoReleaser (if .goreleaser.yaml; signed + notarized macOS binaries) + Zensical docs to Pages (if zensical.toml); vanity tags
merge.yaml any signature-preserving fast-forward merge — /merge now, or /auto-merge (comment/label) when approved + green
merge-review-ack.yaml any companion to merge.yaml — lets fork PRs auto-merge promptly when approved after CI is green
merge-notice.yaml any posts a one-time "this repo merges via /merge" comment on new PRs
add-to-project.yaml any adds newly opened issues to a shared org Projects v2 board via a "Project Sync" App token
renovate.yaml any per-repository Renovate run — the org bot's App, config and preset on this repo's own schedule, plus instant runs on a dashboard / PR checkbox tick

Each workflow below lists its inputs, secrets, and the permission ceiling the caller must grant — a reusable workflow's jobs cannot exceed the permissions of the job that calls them. The snippet is the minimal caller; follow the link beneath it for the fully-commented version.

ci.yaml

Any repo. Runs the canonical mise tasks — lint, build, test — as one parallel job each, with every toolchain (Go, Node, Python, uv) and dev CLI installed from the repo's mise pins, and uploads coverage to Codecov from a job isolated from PR-built code. Tasks the repo does not define are skipped; a repo with no mise config at all fails. A repo that commits its build output has its dist/ verified up to date after mise run build (so a stale committed artifact fails the PR). An opt-in e2e job runs mise run e2e. A caller may add product-specific jobs (e.g. integration) alongside the ci job.

  • Inputs: e2e (default false), coverage (default true; set false for repos that emit no coverage or test results).
  • Secrets: none.
  • Permissions (caller grants): contents: read.
on:
  push:
    branches: [main]
  pull_request:
    branches: ["main", "releases/*"]
permissions:
  contents: read
jobs:
  ci:
    uses: bitwise-media-group/github-workflows/.github/workflows/ci.yaml@v2

Full example: examples/ci.yaml.

security.yaml

Any repo. CodeQL analysis whose language matrix is detected at the repo root: actions (build-free) always, plus go (via autobuild, compiling with the Go toolchain from the repo's mise pins) when a root go.mod exists and javascript-typescript (build-free) when package.json exists.

  • Inputs: config-file (optional, default none; pass ./.github/codeql/codeql-config.yaml, copy examples/codeql-config.yaml, to exclude a bundled dist/).
  • Secrets: none.
  • Permissions (caller grants): security-events: write, packages: read, actions: read, contents: read.
on:
  push:
    branches: [main]
  pull_request:
    branches: ["main", "releases/*"]
  schedule:
    - cron: "28 20 * * 4"
permissions:
  security-events: write
  packages: read
  actions: read
  contents: read
jobs:
  analyze:
    uses: bitwise-media-group/github-workflows/.github/workflows/security.yaml@v2

Full example: examples/security.yaml.

release.yaml

Any repo. Runs release-please (two-pass), then branches by detection: a repo with a .goreleaser.yaml runs GoReleaser (archives, checksums, SBOMs, cosign signatures, Developer ID-signed and notarized macOS binaries, optional Homebrew cask, SLSA attestation) — Go, goreleaser, cosign and syft all come from the repo's mise pins, so the release is built by the same versions as mise run release locally; every other repo's release is just the release-please cut. A repo with a zensical.toml also rebuilds its Zensical docs site (uv run zensical build) and publishes it to GitHub Pages — gated on an actual release and keyed off config presence exactly like the GoReleaser path, so it is independent of GoReleaser (a repo can ship binaries and docs from one release). With vanity-tags: true it also moves the floating major and minor tags (v1 and v1.1). A committed dist/ is verified for freshness in ci.yaml on every PR, not at release time.

  • Inputs: vanity-tags (default false; move the floating v1 / v1.1 tags — set it for Actions/reusable repos whose consumers pin @v1), app-client-id (optional; author the release as a GitHub App rather than GITHUB_TOKEN — vars.FF_MERGE_CLIENT_ID).
  • Secrets: homebrew-tap-token — optional; only needed if .goreleaser.yaml publishes a Homebrew cask to another repo (secrets.HOMEBREW_TAP_GITHUB_TOKEN). app-private-key — optional; required only when app-client-id is set (secrets.FF_MERGE_PRIVATE_KEY). macos-sign-p12, macos-sign-password, macos-notary-issuer-id, macos-notary-key-id, macos-notary-key — optional; only read by a .goreleaser.yaml with a notarize.macos block (secrets.MACOS_SIGN_P12 / MACOS_SIGN_PASSWORD / MACOS_NOTARY_ISSUER_ID / MACOS_NOTARY_KEY_ID / MACOS_NOTARY_KEY). All five are org secrets scoped to the notarizing repos — the shared open-source Developer ID certificate and the team notary key — see macOS notarization: org setup.
  • Auto-merging release PRs: set app-client-id + app-private-key (reuse the "FF Merge" App) so release-please authors the release PR as the App. A release PR whose branch is pushed by the default GITHUB_TOKEN does not emit workflow_run events — GitHub's recursion guard suppresses them — so merge.yaml's auto-merge, which is keyed on workflow_run, never retriggers when its checks go green and the PR only lands on the hourly sweep. Authoring as the App restores the trigger. Skip both inputs if you don't auto-merge release PRs.
  • Publishing docs: add a zensical.toml (plus pyproject.toml + uv.lock) at the repo root and set Settings → Pages → Source → GitHub Actions. On each release the docs job runs uv run zensical build and deploys ./site to Pages. It renders docs/ only — no language build — so a repo whose docs embed generated reference (e.g. a CLI/man dump) must commit that output. Nothing to configure beyond the file and the Pages source.
  • Signing and notarizing macOS binaries: add a notarize.macos block to .goreleaser.yaml and pass the five macos-* secrets. GoReleaser's embedded quill signs each darwin binary with the org's Developer ID certificate and submits it to Apple's notary service from the Linux runner — no macOS runner, no Pro licence — so a downloaded binary runs without a Gatekeeper prompt and the Homebrew cask needs no quarantine-stripping hook. One-time org setup and the consumer-side config are in macOS notarization: org setup.
  • Permissions (caller grants): contents: write, issues: write, pull-requests: write, id-token: write, attestations: write, artifact-metadata: write, pages: write. Grant all seven even without a .goreleaser.yaml or zensical.toml: GitHub resolves a reusable workflow's permissions as the union of every job and ignores if:, so the skipped GoReleaser job's id-token / attestations / artifact-metadata and the docs job's pages are still required or the run fails at startup.
on:
  push:
    branches: [main]
permissions:
  contents: write
  issues: write
  pull-requests: write
  id-token: write # cosign keyless signing + docs Pages OIDC deploy
  attestations: write # github build-provenance attestation
  artifact-metadata: write # artifact storage record for the attestation
  pages: write # publish docs to GitHub Pages (when a zensical.toml exists)
jobs:
  release:
    uses: bitwise-media-group/github-workflows/.github/workflows/release.yaml@v2
    with:
      vanity-tags: true # for Actions/reusable repos pinned @v1
    secrets:
      homebrew-tap-token: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} # optional

Full example: examples/release.yaml.

merge.yaml

Any repo. Signature-preserving fast-forward merge — the manual /merge and the set-and-forget auto-merge in one workflow. /merge merges an approved, green PR now; arming auto-merge (a /auto-merge comment or the auto-merge label) fast-forwards it automatically the moment it is approved and every required check is green. Both run the same ff-merge action with a short-lived App token (commit objects untouched, so signatures survive), and ff-merge re-verifies write access, approval, all checks, and a genuine fast-forward before moving the ref. There is no GitHub event for "all of a repo's own Actions checks finished" (GitHub does not fire check_suite for GITHUB_TOKEN runs), so auto-merge observes completion via workflow_run(completed) listing every required workflow. Requires branch protection that requires PR review. See org setup.

  • Inputs: app-client-id (required; vars.FF_MERGE_CLIENT_ID), merge-command (default /merge), arm-command (default /auto-merge), label (default auto-merge), require-approval (default true), maintainer-only (default true), squash-authors (default bitwise-renovate[bot]; author logins whose PRs are squash-merged via the API instead of fast-forwarded — see below).
  • Renovate PRs (squash instead of fast-forward): Renovate's shared preset arms the update types it does not automerge itself (majors, 0.x) with the auto-merge label, and ff-merge squash-merges PRs from the squash-authors logins rather than fast-forwarding: Renovate never rebases its branches onto the base (rebaseWhen: conflicted), so they are never fast-forwardable, while an API squash needs only a conflict-free branch and lands a GitHub-signed (web-flow) commit that satisfies the required-signatures ruleset. Approval plus green checks is still required, so a breaking-change PR merges the moment a human approves it — no bot rebase queue, no manual merge.
  • Secrets: app-private-key — required (secrets.FF_MERGE_PRIVATE_KEY).
  • Permissions (caller grants): none — the App token does the privileged work, so the caller sets permissions: {}.
  • Triggers (the caller owns them): issue_comment (created) drives /merge and arms /auto-merge; workflow_run (completed, listing your CI workflow name(s) plus Merge Review Ack) attempts the auto-merge once approved and green; the schedule sweeps armed PRs as a backstop. A /merge-only repo can trigger just issue_comment. Every trigger is one that does not attach a check run to the PR (no pull_request, pull_request_review, or pull_request_target), so the workflow leaves no skipped-job clutter on the PR's checks list, and none run PR code.
  • No skipped-check clutter: because the approval signal (the PR-attached pull_request_review event) is captured by the companion merge-review-ack.yaml and re-enters via workflow_run, the only check this system adds to a PR is that companion's single ack job. The arm path also responds only to the /auto-merge comment; adding the auto-merge label by hand still arms (the label is the durable state), and the merge then happens on the next workflow_run or the scheduled sweep.
  • Fork PRs: issue_comment and workflow_run carry base-context secrets on a fork, so fork and same-repo PRs auto-merge identically — arm with /auto-merge and an approval (routed through merge-review-ack.yaml) completes the merge. Install that companion and keep Merge Review Ack in the workflow_run list; the scheduled sweep backstops any missed trigger.
on:
  issue_comment:
    types: [created]
  workflow_run:
    # your CI workflow name(s) — all that must be green — plus the review-ack companion
    workflows: ["CI", "Merge Review Ack"]
    types: [completed]
  schedule:
    - cron: "17 * * * *" # backstop sweep of armed PRs; tune or remove
permissions: {}
jobs:
  merge:
    uses: bitwise-media-group/github-workflows/.github/workflows/merge.yaml@v2
    with:
      app-client-id: ${{ vars.FF_MERGE_CLIENT_ID }}
    secrets:
      app-private-key: ${{ secrets.FF_MERGE_PRIVATE_KEY }}

Full example: examples/merge.yaml. Pair it with merge-review-ack.yaml.

merge-review-ack.yaml

Any repo. Required companion to merge.yaml: it carries the approval signal for auto-merge. merge.yaml deliberately subscribes to no PR-attached events (to avoid skipped-check clutter), but an approval only exists as the PR-attached pull_request_review event — which also carries no secrets on a fork. This workflow's single job completes on an approving review purely so its workflow_run(completed) re-enters merge.yaml's merge-on-review-completed job in base context, where the App token is minted and the fast-forward done. That makes fork and same-repo PRs merge identically on approval, and it is the only check this merge system adds to a PR. It does no privileged work and needs no secrets; ff-merge re-verifies approval, checks, label, and fast-forwardness before moving the ref. Add Merge Review Ack to your merge.yaml caller's workflow_run list.

  • Inputs: none.
  • Secrets: none.
  • Permissions (caller grants): none.
on:
  pull_request_review:
    types: [submitted]
permissions: {}
jobs:
  ack:
    uses: bitwise-media-group/github-workflows/.github/workflows/merge-review-ack.yaml@v2

Full example: examples/merge-review-ack.yaml.

merge-notice.yaml

Any repo. Posts a one-time comment on newly opened PRs explaining the repo merges via /merge. Uses pull_request_target so the notice reaches fork PRs too.

  • Inputs: pr-number — required.
  • Secrets: none.
  • Permissions (caller grants): pull-requests: write.
on:
  pull_request_target:
    types: [opened]
permissions:
  pull-requests: write
jobs:
  notice:
    uses: bitwise-media-group/github-workflows/.github/workflows/merge-notice.yaml@v2
    with:
      pr-number: ${{ github.event.pull_request.number }}

Full example: examples/merge-notice.yaml.

add-to-project.yaml

Any repo. Adds the triggering issue (or PR) to a shared organisation Projects v2 board — the org "Roadmap" — so new issues from every repo collect in one place. GITHUB_TOKEN cannot write an org-level project, so this mints a short-lived token from a dedicated Project Sync GitHub App (the same create-github-app-token pattern as merge.yaml), downscoped to organization-projects: write plus issues / pull-requests: read, and hands it to actions/add-to-project. The App must be installed on the repo whose issue fires the caller — install it on all repositories. In this org the caller is fanned out to every repo by org-config.sh workflows-sync in github-settings.

  • Inputs: project-url (required; https://github.com/orgs/<org>/projects/<n>), app-client-id (required; vars.ADD_TO_PROJECT_CLIENT_ID), labeled (optional; comma-separated labels to filter on), label-operator (optional; AND / OR / NOT, default OR).
  • Secrets: app-private-key — required (secrets.ADD_TO_PROJECT_PRIVATE_KEY).
  • Permissions (caller grants): none — the App token does the privileged work, so the caller job sets permissions: {}.
  • Triggers (the caller owns it): issues (opened) is the usual choice; any event carrying an issue or PR works.
  • Org setup (one-time): create a Project Sync App with organization projects read/write and repository issues + pull requests read, install it on all repositories, and expose it as the ADD_TO_PROJECT_CLIENT_ID variable + ADD_TO_PROJECT_PRIVATE_KEY secret.
on:
  issues:
    types: [opened]
permissions: {}
jobs:
  add:
    uses: bitwise-media-group/github-workflows/.github/workflows/add-to-project.yaml@v4
    with:
      project-url: https://github.com/orgs/bitwise-media-group/projects/1
      app-client-id: ${{ vars.ADD_TO_PROJECT_CLIENT_ID }}
    secrets:
      app-private-key: ${{ secrets.ADD_TO_PROJECT_PRIVATE_KEY }}

Full example: examples/add-to-project.yaml.

renovate.yaml

Any repo. Runs the org's self-hosted Renovate bot against this repository only — same "Renovate" App, same bot-side config and org preset from bitwise-media-group/renovate-config — so each repo has its own schedule and its runs are parallel and independent of the org-wide loop, which grows longer with every repository it walks. The job checks out the config repository and hands renovate-global.json5 to Renovate unchanged, then pins the run to the calling repository through the environment (autodiscover off, repositories = this repo). Beyond the cron, the caller listens for issues: [edited] and pull_request: [edited]: a human ticking a checkbox in the Dependency Dashboard issue or a Renovate PR body (rebase/retry, create rate-limited PRs, …) is a body edit, and the job runs Renovate immediately instead of at the next tick. Renovate's own body rewrites (a Bot sender) are filtered out so a run never triggers the next one, and edits to any other issue or PR skip the job. The merge model is the org bot's: API-created Verified commits, the App's pull-request ruleset bypass for squash-merging green in-policy PRs, one automerge per base branch per run. The App token is scoped to the calling repository, so the config repository must be public (it is).

  • Inputs: app-client-id (required; vars.RENOVATE_CLIENT_ID), config-repository (default bitwise-media-group/renovate-config), config-ref (default main), config-file (default renovate-global.json5), dry-run (default empty = live; extract, lookup or full), log-level (default info).
  • Secrets: app-private-key — required (secrets.RENOVATE_PRIVATE_KEY).
  • Permissions (caller grants): none — the App token does the privileged work, so the caller sets permissions: {}.
  • Triggers (the caller owns it): schedule (hourly — Renovate automerges one PR per base branch per run, so the cron is also the merge throughput), workflow_dispatch (forwarding dry-run / log-level), issues (edited) and pull_request (edited). The pull_request trigger leaves a skipped job on every PR body edit that is not a Renovate checkbox; that is the price of reacting to the checkbox at all.
  • Org setup: the "Renovate" App installed on the repo, exposed as the RENOVATE_CLIENT_ID variable + RENOVATE_PRIVATE_KEY secret (org-level, all repositories). Keep the org-wide bot off a repo that runs this caller — exclude it in the org bot's autodiscoverFilter, or retire that loop once every repo carries the caller — since two bots on one repo race on branches and automerge.
on:
  schedule:
    - cron: "24 * * * *"
  workflow_dispatch:
  issues:
    types: [edited]
  pull_request:
    types: [edited]
permissions: {}
jobs:
  renovate:
    uses: bitwise-media-group/github-workflows/.github/workflows/renovate.yaml@v7
    with:
      app-client-id: ${{ vars.RENOVATE_CLIENT_ID }}
    secrets:
      app-private-key: ${{ secrets.RENOVATE_PRIVATE_KEY }}

Full example: examples/renovate.yaml.

Consumer contracts

The reusable workflows stay free of per-repo configuration by assuming a small contract. The mise config is the language boundary — every repo provides the same canonical tasks and the workflows just run mise run <task>, with every toolchain installed from the same mise pins:

  • mise tasks — defined in a root mise.toml, or by the shared toolchain (bitwise-media-group/toolchain) mounted as a submodule at .mise/: lint (all check-mode static analysis: prettier --check, markdownlint, and for Go go vet / govulncheck), build, test (emitting coverage/cobertura-coverage.xml, optionally coverage/junit.xml), and e2e. Define only the tasks that do real work — CI discovers the task list and skips the rest; coverage is optional. A repo with no mise config fails CI.
  • Toolchains — no setup-go / setup-node / setup-uv: the language runtimes (Go, Node, Python, uv) and every dev CLI (goreleaser, cosign, syft, the linters, …) are installed from the mise pins, sha256-verified against mise.lock and restored from the mise cache, so CI, release and local runs use identical versions. The tasks install their own project dependencies (the workflow never runs npm ci or go mod download itself). A tools-only go.work with a tools/go.mod (for go tool addlicense) is universal dev tooling, so it is not a Go-product signal for CodeQL — only a root go.mod is.
  • CodeQL — scans actions always, go (autobuild, with the Go toolchain from the mise pins) when a root go.mod exists, and javascript-typescript when package.json exists. A repo with a bundled dist/ should pass config-file: ./.github/codeql/codeql-config.yaml (copy examples/codeql-config.yaml) to exclude it.
  • Release — release-please-config.json + .release-please-manifest.json; a .goreleaser.yaml (release-type: go, draft: true) selects the GoReleaser path, otherwise the release is just the release-please cut. A zensical.toml selects the docs path (rebuild the Zensical site and publish to Pages; needs Pages set to GitHub Actions and pages: write). Set vanity-tags: true to move the floating v1 / v1.1 tags (after GoReleaser when present); a committed dist/ is verified in CI, not here. To sign and notarize macOS binaries, add a notarize.macos block to .goreleaser.yaml and pass the macos-* secrets (see macOS notarization: org setup).

A caller may mix a reusable-workflow job with normal jobs — e.g. a Go CLI keeps its product-specific integration / e2e jobs in the same ci.yaml that calls the reusable ci.yaml.

Pinning

The examples pin every uses: to an all-zeros placeholder SHA with the release it stands for in a trailing comment (@0000…0000 # v7.0.0); replace the zeros with that release's full commit SHA (git rev-parse v7.0.0, or the commit listed on the release page) before the caller will run. Policy is a full SHA for every action, first-party included (this library's own zizmor config enforces it), and Renovate keeps the SHA and the version comment in step from there. The # x-release-please-version marker on each uses: line is for this repository's release automation, which rewrites the version comment in every release PR so the examples always name the release they document; drop it or keep it, the copied caller works either way. Avoid @main except for short-lived testing.

Fast-forward merge: org setup

merge.yaml (the /merge + auto-merge flows) drives the bitwise-media-group/ff-merge action; merge-notice.yaml posts the companion convention reminder. The one-time org setup (the "FF Merge" GitHub App, its ruleset bypass, and the FF_MERGE_CLIENT_ID variable + FF_MERGE_PRIVATE_KEY secret) is documented in bitwise-media-group/ff-merge.

Note on App input names. This library's contract is input app-client-id + secret app-private-key, backed by vars.FF_MERGE_CLIENT_ID / secrets.FF_MERGE_PRIVATE_KEY. Existing callers across the org currently use inconsistent names (client-id/app-key, or app-id/app-key with FF_APP_ID/FF_APP_KEY); align them to the names above when migrating to these reusable workflows. The macOS notarization secrets follow the same convention: macos-sign-p12 ← secrets.MACOS_SIGN_P12, macos-sign-password ← MACOS_SIGN_PASSWORD, macos-notary-issuer-id ← MACOS_NOTARY_ISSUER_ID, macos-notary-key-id ← MACOS_NOTARY_KEY_ID, macos-notary-key ← MACOS_NOTARY_KEY.

macOS notarization: org setup

release.yaml signs and notarizes the darwin binaries of any consumer whose .goreleaser.yaml has a notarize.macos block: GoReleaser's embedded quill signs each Mach-O with a Developer ID Application certificate (hardened runtime flag set, Apple timestamp attached) and submits it to Apple's notary service, all on the ubuntu-latest runner — no macOS runner and no GoReleaser Pro. A notarized binary runs from a browser download without a Gatekeeper prompt (the ticket is fetched online on first run; a bare Mach-O cannot be stapled), so the Homebrew cask needs no xattr -dr com.apple.quarantine hook. The setup below is done once for the organisation: one shared Developer ID Application certificate and one App Store Connect team API key, stored as five org secrets visible only to the repositories that notarize. Every Developer ID Application certificate Apple issues to the team carries the identical subject (Developer ID Application: <Team> (<TEAMID>) — the CSR's CN and email are not carried into it), so per-binary certificates would only buy independent revocation at the cost of a .p12 secret per repo.

Prerequisites. Apple Developer Program membership current and the latest agreements accepted, signed in as the Account Holder — the only role that can create Developer ID certificates. gh auth refresh -h github.com -s admin:org once so gh secret set --org works. quill (brew install quill or mise use -g aqua:anchore/quill) only for the optional .p12 check.

Step 1 — the shared signing certificate (once). Make a key and CSR, have Apple issue the certificate against it, and bundle leaf + key into a .p12. The CN and email only label the CSR in the portal; Apple composes the issued subject itself.

name=bitwise-oss
dir=$(mktemp -d)
# key + CSR (RSA-2048)
openssl req -new -newkey rsa:2048 -nodes \
  -keyout "$dir/$name.key" -out "$dir/$name.certSigningRequest" \
  -subj "/emailAddress=oss@bitwisemedia.co.uk/CN=BitWise Media Group OSS Release Signing/C=GB"

At https://developer.apple.com/account/resources/certificates/add: Software → Developer ID → Developer ID Application, profile type G2 Sub-CA, upload $dir/$name.certSigningRequest, then Download (developerID_application.cer).

# p12 = leaf + key. quill attaches Apple's Developer ID G2 chain itself, so no
# `quill p12 attach-chain`; OpenSSL 3's default PBES2/AES-256 encryption decodes
# fine, so do NOT pass -legacy. `-in` needs PEM, hence the DER conversion first.
openssl x509 -inform der -in developerID_application.cer -out "$dir/$name.crt"
# sanity check: the subject must read "Developer ID Application: ..." — a
# Developer ID *Installer* certificate carries a critical extension
# (1.2.840.113635.100.6.1.14) quill rejects with "x509: unhandled critical extension"
openssl x509 -in "$dir/$name.crt" -noout -subject
password=$(openssl rand -base64 24)
openssl pkcs12 -export -inkey "$dir/$name.key" -in "$dir/$name.crt" -name "$name" \
  -out "$dir/$name.p12" -passout "pass:$password"
quill p12 describe "$dir/$name.p12"    # optional: shows the leaf + the resolved chain
# record these, and back the .p12 + password up in the password manager: Apple
# cannot re-issue a private key, so a lost key means revoke + reissue.
openssl x509 -in "$dir/$name.crt" -noout -serial -fingerprint -sha256 -enddate

Step 2 — the team API key (once). App Store Connect → Users and Access → Integrations → App Store Connect API → Team Keys → Generate: name GitHub Actions Notarization, access Developer. Download AuthKey_<KEYID>.p8 (offered once) and copy the Issuer ID (top of the page) and the Key ID.

Step 3 — org secrets, visible to the notarizing repos only. The base64 values are single-line so GitHub masks them in logs. Clean up the local key material once the secrets are set and the backup is in the password manager.

org=bitwise-media-group; repos=dotty,patchy,evolve
gh secret set MACOS_SIGN_P12         --org $org --visibility selected --repos $repos \
  --body "$(base64 -i "$dir/$name.p12" | tr -d '\n')"
gh secret set MACOS_SIGN_PASSWORD    --org $org --visibility selected --repos $repos --body "$password"
gh secret set MACOS_NOTARY_ISSUER_ID --org $org --visibility selected --repos $repos --body "<issuer id>"
gh secret set MACOS_NOTARY_KEY_ID    --org $org --visibility selected --repos $repos --body "<key id>"
gh secret set MACOS_NOTARY_KEY       --org $org --visibility selected --repos $repos \
  --body "$(base64 -i ~/Downloads/AuthKey_<KEYID>.p8 | tr -d '\n')"

rm -rf "$dir" ~/Downloads/AuthKey_<KEYID>.p8 ~/Downloads/developerID_application.cer

Consumer side. Put darwin in its own build id and limit binary_signs.ids to the non-darwin ids: GoReleaser runs binary_signs (cosign) before notarization, which rewrites the Mach-O in place, so a cosign bundle over the darwin binary would never verify against what ships — and the OSS binary_signs has no per-artifact if, so the build id is the only handle. The darwin binaries are covered by the Apple signature and notarization plus the SLSA attestation over checksums.txt. Then add the block and pass the five secrets from the caller (commented lines in examples/release.yaml):

notarize:
  macos:
    # every real release notarizes (and fails if the secrets are missing); a
    # snapshot only does when MACOS_SIGN_P12 is exported, so `goreleaser
    # release --snapshot` stays offline and a local end-to-end test is one env
    # export away
    - enabled: '{{ or (not .IsSnapshot) (isEnvSet "MACOS_SIGN_P12") }}'
      ids: [<darwin build id>]
      sign:
        certificate: "{{ .Env.MACOS_SIGN_P12 }}"
        password: "{{ .Env.MACOS_SIGN_PASSWORD }}"
      notarize:
        issuer_id: "{{ .Env.MACOS_NOTARY_ISSUER_ID }}"
        key_id: "{{ .Env.MACOS_NOTARY_KEY_ID }}"
        key: "{{ .Env.MACOS_NOTARY_KEY }}"
        wait: true
        timeout: 15m

Verify a shipped binary on a Mac with codesign -dv --verbose=4 <binary> (Authority = Developer ID Application: … (TEAMID), flags include runtime) and spctl -a -vv -t install <binary> (source=Notarized Developer ID).

Onboarding another repo. Add it to each secret's repository list — org Settings → Secrets and variables → Actions, or gh api -X PUT orgs/$org/actions/secrets/<NAME>/repositories/$(gh api repos/$org/<repo> --jq .id) for each of the five — then make the consumer-side changes. No new Apple material is needed.

Rotation and revocation. The certificate is valid for five years. Revoking it in the developer portal invalidates future signing for every repo at once (binaries already notarized keep running), so rotate by issuing a new certificate with step 1 and re-running the MACOS_SIGN_P12 / MACOS_SIGN_PASSWORD gh secret set. The API key is revoked in App Store Connect → Team Keys; generate a new one and re-run the three MACOS_NOTARY_* gh secret sets.

Testing changes

This repo dogfoods its own reusable workflows by local path: self-ci.yaml calls ci.yaml (which installs the tools pinned by the .mise/ toolchain submodule and runs the one task this repo defines, lint) and self-release.yaml calls release.yaml (no .goreleaser.yaml, so just the release-please cut plus the vanity-tags job). self-security.yaml stays a bespoke actions-only scan: the library has no compilable Go and no JS/TS product source, so an actions CodeQL pass is the whole surface. The /merge + auto-merge flows (self-merge.yaml), its fork-PR review-ack companion (self-merge-review-ack.yaml), the merge notice (self-merge-notice.yaml) and the per-repo Renovate run (self-renovate.yaml) dogfood the rest. That Renovate run is also this repo's own dependency automation (action SHA pins and the .mise submodule tag), on the org bot's config from bitwise-media-group/renovate-config. Validate a change to a reusable workflow by temporarily pointing a real consumer's caller at a feature branch or SHA (@your-branch) and opening a PR there.

Releasing this repo

self-release.yaml calls the reusable release.yaml (release-type: simple, vanity-tags: true) on pushes to main; merging the release PR cuts vX.Y.Z and moves the floating major and minor tags (v2, v2.1). Consumers pin to those tags.

About

A collection of reusable workflows used by BitWise Media Group repositories.

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages