Skip to content
Closed
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
29 changes: 29 additions & 0 deletions .github/workflows/public-text-audit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# run-tree-ok: text edits need independent audit concurrency and results;
# CLA and application checks have different event and permission contracts.
# Separate workflow and job name: comment success must not satisfy the PR gate.
name: Public text audit

on:
issues:
types: [opened, edited]
issue_comment:
types: [created, edited]
pull_request_review:
types: [submitted, edited]
pull_request_review_comment:
types: [created, edited]

permissions:
contents: read
pull-requests: read
issues: read

concurrency:
group: public-text-audit-${{ github.event_name }}-${{ github.event.comment.id || github.event.review.id || github.event.issue.number }}
cancel-in-progress: true

jobs:
public-text-audit:
uses: mindsdb/github-actions/.github/workflows/public-text-check.yml@main
secrets:
PUBLIC_TEXT_DENYLIST: ${{ secrets.PUBLIC_TEXT_DENYLIST }}
36 changes: 36 additions & 0 deletions .github/workflows/public-text-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Public text reusable

on:
workflow_call:
secrets:
PUBLIC_TEXT_DENYLIST:
description: Private regular expression for restricted public text
required: false

permissions:
contents: read
pull-requests: read
issues: read

jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out the trusted scanner
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
repository: mindsdb/github-actions
ref: ${{ job.workflow_sha }}
path: .ci-shared
persist-credentials: false
sparse-checkout: scripts/public_text_check.py
sparse-checkout-cone-mode: false
- name: Scan current public text
env:
GH_TOKEN: ${{ github.token }}
PUBLIC_TEXT_DENYLIST: ${{ secrets.PUBLIC_TEXT_DENYLIST }}
REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
EVENT_PATH: ${{ github.event_path }}
run: python3 -I .ci-shared/scripts/public_text_check.py
22 changes: 22 additions & 0 deletions .github/workflows/public-text.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# run-tree-ok: PR metadata edits must rerun this stable text gate without
# retriggering application build or deployment pipelines.
name: Public text

on:
pull_request:
types: [opened, edited, synchronize, reopened]

permissions:
contents: read
pull-requests: read
issues: read

concurrency:
group: public-text-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
public-text:
uses: mindsdb/github-actions/.github/workflows/public-text-check.yml@main
secrets:
PUBLIC_TEXT_DENYLIST: ${{ secrets.PUBLIC_TEXT_DENYLIST }}
18 changes: 17 additions & 1 deletion .github/workflows/release-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,22 @@ jobs:
run: |
set -euo pipefail

COMMITS=$(git log --oneline --no-merges "origin/${BASE}..origin/${STAGING}")
# A squash merge never makes the staging commits part of the base, so
# BASE..STAGING still lists every train that already shipped. List only
# what the newest merged train did not carry: the same commits the
# public text check reads. Fall back to the whole range when staging
# no longer descends from that train's head.
RANGE=("origin/${BASE}..origin/${STAGING}")
SINCE_LINE=""
LAST=$(gh api --method GET --paginate --slurp "repos/${REPO}/pulls" \
-f state=closed -f base="${BASE}" -f head="${REPO%%/*}:${STAGING}" -f per_page=100 \
| jq -r --arg repo "${REPO}" \
'[.[][] | select(.merged_at and .head.repo.full_name == $repo)] | max_by(.merged_at) // empty | "\(.number) \(.head.sha)"')
if [ -n "$LAST" ] && git merge-base --is-ancestor "${LAST#* }" "origin/${STAGING}" 2>/dev/null; then
RANGE=("origin/${STAGING}" "^${LAST#* }" "^origin/${BASE}")
SINCE_LINE="Commits since #${LAST%% *}, the last merged release. Earlier ones already shipped."
fi
COMMITS=$(git log --oneline --no-merges "${RANGE[@]}")
CONTRIBUTORS=$(git log --format='%an' "origin/${BASE}..origin/${STAGING}" | sort -u | sed 's/^/- /')

# Find the PR before writing about it. The body describes whether this is
Expand Down Expand Up @@ -182,6 +197,7 @@ jobs:
**${AHEAD} commit(s)** queued for the next production release.

### Changes
${SINCE_LINE}
\`\`\`
${COMMITS}
\`\`\`
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -742,3 +742,9 @@ jobs:
uses: mindsdb/github-actions/.github/workflows/release-pr.yml@main
secrets: inherit
```

## Public text guard

[`public-text-check.yml`](.github/workflows/public-text-check.yml) checks PR text, commit messages, current added content and supported issue/comment/review text against a private regular expression. It checks only API data, runs the trusted script at the reusable workflow's commit, and never prints matching text or paths. Separate PR and audit callers prevent a comment's result from satisfying the merge check.

See [coverage, exceptions and rollout](docs/public-text.md) before enabling the required check. Missing configuration and incomplete scans fail; fork events carry an explicit unscanned exception. The guard runs after publication and cannot remove history. The implementation and this repository's callers must merge before application callers use it.
79 changes: 79 additions & 0 deletions docs/public-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Public text guard

The guard fails a PR or text-audit run when current public text matches the private `PUBLIC_TEXT_DENYLIST` regular expression. It reports a field, ordinal and line number without quoting the text or its filename. A failure catches a disclosure after GitHub saves it; the guard cannot prevent publication or erase older revisions.

## Coverage

| Event | Text read from the API | Result context |
| --- | --- | --- |
| PR opened, edited, synchronized or reopened | Current title, description, head branch, commit messages an earlier merged PR from the branch did not carry, current added lines and paths | `public-text / check` (verify on the first live run) |
| Issue opened or edited | Current title and description | `public-text-audit / check` |
| Discussion comment created or edited | Current comment body | `public-text-audit / check` |
| Review submitted or edited | Current review summary | `public-text-audit / check` |
| Inline review comment created or edited | Current comment body | `public-text-audit / check` |

The PR and audit callers have separate workflow names, job names and concurrency groups. A successful comment audit cannot satisfy the PR check. Reruns read current text. The scanner reads the same fields again before reporting success, and fails if they change. A PR run for an older head fails instead of reporting on a newer head under the old check.

Release trains receive the same scan as any other PR. The scanner compares paginated collection sizes with the PR's counts. The PR commits endpoint stops at 250 commits, so a larger PR lists its commits through the compare endpoint, which must return the PR's exact count and every commit the PR endpoint listed. More than 3,000 files exceeds the files endpoint's limit and fails as incomplete. Duplicate data, malformed hunks and added-line count mismatches also fail.

A squash merge never makes the head branch's commits part of the base, so a release train's next PR lists every earlier train's commits again. The scanner skips commits that the newest earlier merged PR from the same branch into the same base already carried, when the current head still descends from that PR's head. Those messages merged once and stay in the branch's public history; failing on them would block every later train on text nobody can change. Every other commit message is checked. If the head no longer descends from the merged head, every commit is checked. The release-train PR description lists the same new commits, so a carried commit's subject does not return through the description. Failure to fetch or parse the release history stops the body update before creating or editing a PR. A successful lookup with no earlier train, or an unsuccessful ancestry check, still uses the full range.

Removed paths are excluded so a cleanup can remove a disclosure. Renames check only the new path and added content. A modified or renamed file with zero additions and zero deletions can omit its patch only when the scanner verifies that its blob equals the base file's blob, or verifies empty/binary content. This permits mode-only changes and unchanged renames without trusting line counts alone. A missing base path fails conservatively.

For other missing patches, the scanner reads the blob as data. It accepts an empty file, and treats a blob of up to 10 MiB with a NUL byte within the first 8,000 bytes as binary, checking only its path. GitHub also omits patches for files it renders as binary without a NUL byte, such as small PDFs. For an added file of up to 1 MiB with no NUL byte, the whole content is its added text, so the scanner checks all of it. Any other missing patch fails and needs maintainer review: a larger blob, or a modified file whose new content has no patch. Text stored with NUL bytes, such as UTF-16, counts as binary.

Patchless text must decode as UTF-8 without replacing characters. The only fallback is a PDF binary-marker comment immediately after a `%PDF-1.0` through `%PDF-1.7` or `%PDF-2.0` header: a second line containing `%` and at least four bytes all in the range 128–255, with LF or CRLF line endings. That marker is represented one-to-one as Latin-1 and scanned; every remaining byte must decode as UTF-8. This preserves small PDFs with a binary marker and readable text, without guessing the encoding of their bodies. Other undecodable content fails as incomplete, including Latin-1 source files. A filename or encoding declaration does not grant an exception.

Added text is checked in contiguous blocks so a wrapped phrase can match. The scanner does not join additions across intervening unchanged lines.

## Trust and exceptions

The reusable checks out only `mindsdb/github-actions` at `job.workflow_sha`, the commit of the called workflow. It never checks out contributor code. The token has only contents, issues and pull-request read permissions. Callers pass only the named secret. Existing repository convention makes callers track `@main`; a changed shared workflow takes effect for every caller after merge.

| Situation | Behavior |
| --- | --- |
| Same-repository PR with missing or invalid secret | Fails, unscanned |
| Dependabot event without the configured secret | Fails, unscanned; Dependabot runs receive only Dependabot secrets, so configure a Dependabot copy of the pattern or handle each Dependabot PR manually |
| Fork PR, review or inline comment | Explicit successful exception labeled `NOT SCANNED`; maintainer inspection required |
| Discussion comment on a fork PR | Default-branch audit reads and checks the comment |
| API failure, deleted text, unavailable head, incomplete data or concurrent edit | Fails, incomplete or superseded |

The guard protects against accidental disclosures. Contributors who can alter trusted workflows or use privileged repository settings can bypass it. A fork exception is not a clean scan. This design does not add a privileged `pull_request_target` workflow.

## Public-writing policy

Use neutral roles and public component contracts. Do not identify customers, partners or users, or disclose private repository names, links, PR or issue references, release versions or schedules. Generic deployment terminology and public configuration values remain allowed.

Maintainers supply restricted terms and aliases through the private regular expression. Do not put real terms in this repository's code, tests, workflow configuration, examples or logs. Fixtures use synthetic text only. The regular expression detects configured strings; it cannot infer that an otherwise ordinary version number or schedule refers to a private project. Human review must check that context.

The secret keeps the configured list out of source, but public pass/fail results can reveal whether a guessed term matches. The check is not a secrecy guarantee for the list. Use a bounded, reviewed expression; pathological regular expressions can exhaust the job timeout.

## Rollout

1. Merge the shared scanner, tests, reusable and this repository's callers first. The initial implementation PR cannot resolve its new `@main` reusable until that merge; use the existing CI and local checks for bootstrap review. Do not require the new context before it exists. Until step 2, this repository's own PRs report the missing-secret failure.
2. An operator configures `PUBLIC_TEXT_DENYLIST` as an organization Actions secret visible only to selected repositories, including this one, and a Dependabot secret with the same value for the same repositories. The pattern includes a synthetic canary such as `zqcanary7731`. Never put the pattern into a public ruleset or shell command history.
3. Merge each application caller into its integration branch, then promote it to the default branch. Issues and discussion-comment events depend on default-branch workflow availability. Callers that land before step 2 turn every same-repository PR red.
4. Verify a same-repository PR fails on the canary and passes after an edit removes it. Put the canary only in a description or comment, never in a commit message or branch name, and remove it before merge: a squash merge can copy the description or commit messages into history that later trains carry. Observe the actual Actions check context before requiring it for default-branch and staging merges.
5. Verify an audit can fail without replacing the PR check. Edit the audited text, rerun the old event, and verify that the latest body is checked. Test the explicit fork exception and missing-secret failure.
6. The operator installs the verified PR context as a required GitHub Actions check. Require the full `public-text / check` context with the GitHub Actions source, never a bare `check`, which the audit job shares. Automation that pushes the protected branch directly, such as a back-merge from the default branch into staging, needs a bypass entry. Required-workflow rules alone do not cover edited PR descriptions.

Workflow authors do not run these operator steps automatically. Missing configuration deliberately blocks same-repository runs. Coordinate the rollout before making the check required. A release train's first scan after activation checks every commit since the last merged train, so a disclosure there can block a merge even when the current diff is clean; it needs a maintainer decision because pushed history cannot change.

## Limits and response

GitHub may not deliver these events in every case. PR workflows do not run on merge conflicts; supported CI-skip directives can suppress runs; most writes made with `GITHUB_TOKEN` do not trigger a new workflow. Review these cases manually before merge. A required check cannot make an event happen.

The scanner does not read intermediate commit file content, binary content, release descriptions, old text revisions, commit messages an earlier merged PR from the same branch already carried, or every existing issue and comment. Added-then-removed content can remain in commit history. A clean result concerns the configured pattern and scanned fields only. Editing a current description leaves older revisions visible; the repository owner handles history cleanup and pushed-commit rewrites separately.

Actions notifications depend on each person's notification settings. A failed audit creates a failed run, not a guaranteed message to its author. This workflow posts no public comments and sends no chat messages.

## Local verification

```sh
uv run --with pyyaml --with pytest --python 3.12 -m pytest tests/ -q
actionlint
uv run --with pyyaml --python 3.12 scripts/workflow_graph.py --workflow-dir .github/workflows --allow-external-reusables
zizmor .github/workflows
```

Unit tests use synthetic API responses and patterns, exercise incomplete and stale data, and assert that failures never echo sensitive text. The live event matrix remains an operator acceptance step after publication.
Loading
Loading