Important
This repository is for use by the Cratis organization only. The reusable workflows here are designed specifically for the Cratis GitHub organization and include runtime validation that rejects calls from outside it.
Common reusable GitHub Actions workflows for Cratis repositories.
.github/workflows/close-superseded-bump-pulls.yml finds older open bump PRs
superseded by a newer open PR against the same base. The newer PR must include all
of the older PR's dependencies, with no lower version and at least one higher
version. Titles such as Bump Arc from 19.0 to 19.1 and Chronicle to 19.2 and
align-versions/stage-A-scene-B-screenplay-C branches are recognized.
| Input | Default | Purpose |
|---|---|---|
branch-prefixes |
align-versions/ |
Space-separated head branch prefixes required for replacements, closure, and branch deletion. An empty list permits none of these. |
authors |
github-actions[bot] |
Space-separated trusted bot/PAT logins whose superseded PRs may be closed. |
dry-run |
false |
Print planned actions without any writes. |
runs-on |
ubuntu-latest |
Runner label; use a self-hosted runner for private repositories. |
workflows-ref |
main |
Trusted Cratis/Workflows ref providing the program, not a PR head ref. |
A replacement PR must have an author listed in authors, a head in the caller
repository, and a branch matching a configured prefix. Untrusted PRs, including
forks and non-prefix branches, never trigger comments, closure, or deletion.
The same three checks are required to close an older PR and delete its branch.
Other older PRs superseded by a trusted replacement (including human-authored
bumps) receive only one Superseded by #Q comment per replacement. An empty prefix
list permits no replacements or closures. Do not add human contributors to
authors. Dependabot PRs and keep-open PRs are never closed. API failures fail
the job. The workflow never checks out PR code: it checks out only
Cratis/Workflows at workflows-ref to run the supporting program.
Example caller (choose a repository-specific, nonzero schedule minute):
name: Close superseded bumps
on:
pull_request_target:
types: [opened, reopened, edited]
schedule:
- cron: '23 * * * *'
workflow_dispatch:
permissions:
pull-requests: write
contents: write
jobs:
close-superseded:
uses: Cratis/Workflows/.github/workflows/close-superseded-bump-pulls.yml@main
with:
branch-prefixes: align-versions/
authors: github-actions[bot]The hourly sweep also catches PRs opened with GITHUB_TOKEN, whose events do not
normally start other workflows. Start with dry-run: true before enabling writes.
.github/workflows/update-packages.yml updates detected NuGet, NPM, Gradle and
Mix dependencies in the calling repository. Existing callers retain their
runner, token fallback, ecosystem commands, and legacy direct-push/rebase behavior.
Nothing opts a repository into PRs or a release policy automatically.
NPM keeps the existing npx npm-check-updates -u -w -x "$exclusions" command,
without a --dep override. ncu's default sections (which exclude peers) and each
caller's .ncurc remain authoritative, including opt-in peer updates and exclusions
of production or packageManager updates. ncu's peer-range behavior is unchanged.
After ncu, an unchanged ^<pin> or ~<pin> peer that exactly matched a local
production/development pin before the update follows its new exact pin only if
the old peer range still admits it. Carets stay within the same major for 1.x+,
the same minor for 0.x, and the identical version tuple for 0.0.x; tildes stay
within the same major/minor. SemVer lower bounds and prerelease restrictions also
apply. An out-of-range pin leaves the peer unchanged and emits a warning naming
the manifest, package, old range and new pin; it does not fail the update. Such
compatibility changes require manual review. Unions, lower bounds, bounded ranges,
wildcards, unmatched peers and peers already changed by ncu are not synchronized.
The snapshot covers the root and declared workspaces (workspaces or
workspaces.packages), not unrelated fixtures or templates; non-object manifests
are skipped with a notice and null/non-object dependency sections are treated as
empty. Symlinked manifests remain refused.
After installation, Yarn 2.2+ dedupes compatible transitive resolutions so a bumped direct range does not leave a second older instance. Yarn 1 and Yarn 2.0/2.1 skip dedupe.
| Optional input | Default | Contract |
|---|---|---|
post-update-command |
Empty (skipped) | Trusted Bash command in the caller root, after all ecosystem updates and before any build. Runs with -euo pipefail; a nonzero exit blocks both publication modes. It runs even when the updaters changed nothing, because its own changes can be the update. |
publication-mode |
direct |
direct retains legacy publication; pull-request uses the validated-candidate rules below. Other values fail. |
pull-request-label |
Empty (no label) | One existing, caller-chosen label, only in pull-request mode. Must match [a-zA-Z0-9][a-zA-Z0-9_.:/-]*. No comma lists, whitespace, or implicit release intent. |
node-version |
24 |
Node.js version for NPM updates. Satisfies the Cratis JavaScript repositories' engine floors; a caller may override it, but the minimum is Node 22 (the peer sync script needs fs.globSync with array exclude, available from 22.14/23.7). |
java-version |
21 |
JDK version for Gradle updates. A caller whose Gradle toolchain needs another JDK (for example jvmToolchain(17)) passes it here. |
runs-on |
ubuntu-latest |
Existing runner-label input; private callers should retain their approved self-hosted label. |
npm-package-exclusions |
typescript |
Existing comma-delimited exclusions, supplemented by the caller's .github/update-packages-npm-exclusions. |
nuget-package-exclusions |
StyleCop.Analyzers (its 1.3.x betas fail the build of repositories on 1.2.0-beta.556; a caller that sets this input replaces the default and lists the package itself to keep it held) |
Comma-delimited exact NuGet package IDs, matched case-insensitively after trimming whitespace. Applies to top-level project references, including centrally managed versions in Directory.Packages.props; transitive-only IDs warn as not directly referenced (updating a parent can still change transitive versions). Prerelease references retain the updater's prerelease eligibility; stable references do not gain prerelease-only candidates. Invalid/incomplete listings and divergent per-framework versions for eligible packages fail before updating. As with the default path, SDK exit 3 (no changes written) skips NuGet updates for that run. |
No-op runs cost no build. After the updaters and the post-update command, the
workflow checks git status (tracked changes plus new files that are not ignored).
When nothing changed it logs No package changes; skipping builds and publication.
and skips every ecosystem build, the candidate commit, the direct-mode push and the
pull request; the job succeeds. A Git error in that check fails the job instead of
reading as "no changes".
.NET SDK selection. The workflow installs SDK 10.0.x plus whatever the caller's global.json pins, so a
repository opts in to .NET 11 by pinning an SDK 11 there; callers without a pin keep SDK 10. The
publish template lists DOTNET11_VERSION next to 8, 9 and 10.
NuGet packages come from the Actions cache. When .NET is detected, the workflow
restores ~/.nuget/packages with actions/cache/restore under
nuget-v1-<runner OS>-<hash>, hashed before any update from **/packages.lock.json,
**/*.props, **/*.targets, **/*.csproj, **/[Nn]u[Gg]et.[Cc]onfig, *.slnx and
global.json, with the nuget-v1-<runner OS>- prefix as fallback. A caller whose own
.NET builds cache ~/.nuget/packages under the same key shares that cache. actions/cache with
lookup-only saves the cache at the end of a successful job when that key does not
exist yet. Without any key file the run restores packages from the feeds as before.
An individually approved caller can use this wrapper (replace
REVIEWED_COMMIT_SHA with the reviewed Workflows commit). The script path is an
example: the caller must commit its own implementation before enabling the hook.
name: Update Packages
on:
workflow_dispatch:
# Needed if the secret falls back to GITHUB_TOKEN; subject to caller/org policy.
permissions:
contents: write
pull-requests: write
jobs:
update:
uses: Cratis/Workflows/.github/workflows/update-packages.yml@REVIEWED_COMMIT_SHA
with:
runs-on: cratis-arc
publication-mode: pull-request
nuget-package-exclusions: Cratis.Fundamentals
post-update-command: bash .github/scripts/sync-package-image-versions.sh
# Optional: set this variable only to the caller's intended existing label.
pull-request-label: ${{ vars.PACKAGE_UPDATE_PR_LABEL }}
secrets:
PAT_WORKFLOWS: ${{ secrets.PAT_WORKFLOWS || github.token }}The checkout is not Workflows: helper scripts in this repository are unavailable at runtime. The publication implementation is inline. Hook commands must be trusted, static caller configuration, never interpolated event text; they run with the same checkout access as dependency tools. Child scripts must propagate errors themselves (the outer strict Bash cannot detect an error a child deliberately suppresses). Hooks should only synchronize intended dependency-related tracked files, such as Stage's Dockerfile image version, and must not commit, push, change Git configuration, or start background writers.
- The checkout must equal the current remote default-branch tip, with a clean tracked worktree/index. A dispatch from a different/stale branch fails before updates.
- After updates and the hook, tracked changes are committed before builds. Thus
package and Dockerfile changes share the exact commit whose tracked tree is built.
Only
git add --updateis used. Untracked/ignored files, caches, and.ai-work/are never swept into the candidate; even staged new paths are rejected. - New hook-owned files (including renames to new paths) require a separate reviewed
commit first. Pre-track them before enabling the updater. Unstaged new files
are not published and must not be required source inputs for validating the candidate.
This mode deliberately does not offer an unrestricted
git addhook escape hatch. - All detected ecosystem builds must succeed. Tracked worktree/index changes made by builds invalidate the candidate instead of being committed or discarded. Ordinary untracked/ignored build outputs are permitted but never published. If nothing changed after the updates and the hook, builds are skipped and no branch or PR is created. If only untracked files changed, builds run but no branch or PR is created.
- Immediately before/after pushing and after PR creation, the workflow checks the original base and candidate identities. Any observed base/default-branch change, candidate commit/tree/index drift, API failure, or readback mismatch fails the job. There is no rebase, reset, amend, force push, retry, or automatic merge in this mode.
- The exact commit is pushed only to
automation/package-update-<GITHUB_RUN_ID>-<GITHUB_RUN_ATTEMPT>. After base checks,POST /repos/{repository}/git/refsatomically reserves this branch at the already remote validated base, requiring absence (an existing branch returns 422). A prior existence lookup is advisory, not the ownership boundary. Successful exclusive creation and matching API response/API and Git readbacks are required before a normal, nonforce push of the exact candidate. No existing ref is patched or reused, even if it points at the same base; API errors never fall through to push. gh pr createuses explicit--repo,--head,--base, title and body, so gh does not implicitly push/fork or prompt. It creates an ordinary PR against the default branch, with only the optional caller-selected label. The body is a user-facing## Changedrelease note, not an internal validation narrative.- At most one open package-update PR. Before reserving a branch, the workflow lists
the caller's open PRs and considers only its own: head branch in the caller
repository (never a fork) named
automation/package-update-<run>-<attempt>, the same base, and a head that is a singlechore: update packagescommit bygithub-actions[bot]whose parent is on the base. Any other PR, including one of its PRs a person has pushed to, is never compared, closed or deleted.- If one of those PRs carries a byte-identical update diff (
git diff --binary --full-indexagainst its own parent), no branch or PR is created and a notice names that PR. The newest such PR is kept, and every other workflow-owned package-update PR, identical or not, is superseded by it, so exactly one stays open. - Otherwise the new PR is created as above, and only after its readback succeeds are the older ones superseded.
- Superseding a PR means commenting with a link to the kept PR, closing it, and deleting its branch when that branch still points at the inspected commit (GitHub's Restore branch on the closed PR undoes this). A PR whose state, head or base changed after the lookup is left open with a warning.
- A failed or unexpected lookup fails the job before anything is published; a failed comment, close or branch deletion fails the job after naming the PR. No step is retried.
- If one of those PRs carries a byte-identical update diff (
These stronger staging and identity guarantees do not retrofit legacy direct
mode's inherited git add -A and rebase loop. Use pull-request for reviewed delivery.
The secret remains optional and the caller interface is unchanged. Only the
publish job uses PAT_WORKFLOWS || github.token, for its fresh checkout and GitHub
writes. The update job checks out with github.token and
persist-credentials: false; its API reads use that built-in token, never the PAT.
All package tooling, the post-update command and builds stay in update. A
one-day artifact carries the frozen commit as a Git bundle plus base, branch,
publication options and PR text. After a successful update with changes, publish
checks out the recorded base and verifies the artifact's metadata, parent, commit
and tree before applying it. It runs only inline Git/gh publication logic, never
repository scripts or package tooling. Direct mode still commits after builds and
retries rejected pushes with its existing rebase loop; PR mode still freezes before
builds and never rebases. The artifact name is stable within a workflow run, so
re-running only a failed publish job reuses the successful update's artifact; a
rerun of update replaces it.
Both modes refuse the entire candidate if it changes .github/ (including
workflows, actions, CODEOWNERS and Dependabot), .cratis/ai/hooks/, .pi/,
.claude/, .mcp.json, .vscode/mcp.json, .gitattributes, .gitmodules, or Git
configuration/hook paths. Git paths are conservatively protected whenever any
path component starts with .git (including .gitconfig and .githooks/).
This includes additions, deletions, renames in either direction, file type or
executable-bit changes, and replacing protected ancestors with files or symlinks.
The update job checks before upload; publish independently
checks the bundled commit before checkout or any write. In particular,
post-update-command can no longer change these paths: make such changes in a
separately reviewed change, not a package-update candidate. Failures list every
offending path.
No permissions block or repository/organization setting is changed by the reusable
workflow. The built-in token must have repository read access even when publishing
uses a PAT or App. Provision Bash, Git and gh on the chosen runner in addition to the
existing ecosystem prerequisites. Use isolated, ephemeral runners: splitting jobs
does not make a persistent self-hosted runner safe if dependency code can leave
processes or alter tools for a later job.
Optional PR-only GitHub App authentication. Set the caller's repository or
organization configuration variable CRATIS_AUTOMATION_APP_ID and pass the optional
AUTOMATION_APP_PRIVATE_KEY workflow secret. For example, add
AUTOMATION_APP_PRIVATE_KEY: ${{ secrets.AUTOMATION_APP_PRIVATE_KEY }} to the
wrapper's secrets mapping above. Reusable workflows use the caller's variables,
so no additional input is needed. Both values must be available or both absent:
a partial configuration fails publish with a configuration error, in either mode.
With neither present, the existing PAT_WORKFLOWS || github.token path is unchanged.
With both present, only publication-mode: pull-request mints an installation token;
direct still uses the existing PAT/token path, never the App.
Set the variable per repository while piloting: an organization-level variable reaches
every caller, and bootstrapped wrappers do not pass the key, so their publish jobs
would fail until each wrapper passes it.
Install the App on the approved caller repository with Contents: write and
Pull requests: write (Metadata: read is implicit). The mint requests only that
repository and those two write permissions; the App needs no default-branch
bypass, and the workflow never enables auto-merge. The private key is supplied
only to the mint action in publish; the token is used only by its checkout and
PR publication step, never by package tools in update. Default post-job token
revocation remains enabled. App-created PRs do trigger downstream workflows,
unlike the built-in token; caller CI and branch protection still decide mergeability.
Direct publication still requires the existing PAT actor's bypass where branch
protection blocks direct writes. Configuring the App does not grant that bypass.
- A classic PAT needs
repo; a fine-grained PAT needs repository Contents: write, Pull requests: write, and Metadata: read, including access to the selected existing PR label. Repository branch/ruleset policies must allow the new automation branch, and deleting a superseded one. PR mode does not require default-branch bypass. The same permissions cover listing, commenting on and closing superseded PRs; no further permission is needed. GITHUB_TOKENneeds caller-grantedcontents: writeandpull-requests: write. The caller cannot elevate beyond organization/repository policy, and the existing Allow GitHub Actions to create and approve pull requests policy must permit PR creation. A disabled policy or missing permission is a blocking error, not a reason to fall back to direct pushes or change settings.- Events created using
GITHUB_TOKENgenerally do not trigger downstream workflows (except explicit dispatch events). A PR created successfully with that token therefore does not prove PR checks will start. Use an approved PAT or the PR-only App path when ordinary push/PR/label events must trigger caller CI/release-intent gates; this workflow dispatches no compensating workflows. PAT permissions are independent of the workflow'sGITHUB_TOKENpermission block.
Ref reservation, Git push and GitHub PR APIs are not one transaction. A base/branch can move between checks or after the final check; branch protection and fresh PR checks remain the merge authority. Reservation establishes creation ownership, not an exclusive lock against later writers. A failed API response can be ambiguous; reservation/readback/push failure can leave a base-only branch without a PR. Later races, failed PR/label operations, or inconsistent readback can leave a candidate branch or ordinary PR while the job fails. The job reports these possible leftovers loudly. Inspect ownership and state before any manual recovery; such failed-run leftovers are never cleaned up automatically, no branch is reused, and nothing is retried. The only automatic cleanup is superseding the workflow's own earlier open PRs described above; a base-only reservation branch without a PR is not considered. A rerun starts from a fresh checkout and validates anew on a new attempt branch; it skips publication when an open PR already carries the same update, and otherwise supersedes it. The workflow never makes an old PR eligible for automatic merge.
Deployment boundary: package-update implementation changes are excluded from
Bootstrap Common Workflows push paths: the canonical wrapper template is unchanged,
existing callers already reference the reused implementation, and an implementation
update must not rewrite unrelated repositories. The bootstrap workflow itself is not
in those paths either, so this narrowly scoped rollout triggers no organization-wide
write workflow. All other watched workflow/template and bootstrap-script
triggers remain intact; changing those still requires separate deployment approval.
Stage is explicitly excluded from bootstrap because it owns its reviewed
package-update/kernel guard workflow and pin, which bootstrap must not overwrite.
No organization-wide wrapper migration or bootstrap execution is part of this change.
Existing @main callers still adopt implementation changes immediately; a targeted
caller such as Stage should retain its reviewed immutable pin.
python3 .github/scripts/tests/update-packages.test.py
python3 .github/scripts/tests/bootstrap-package-update-safety.test.py
python3 .github/scripts/tests/bootstrap-schedules.test.py
actionlint .github/workflows/update-packages.yml .github/workflows/verify-package-updates.yml \
.github/workflows/bootstrap-common-workflows.ymlThe tests extract and execute the actual inline Bash using temporary local bare Git repositories and stub gh/toolchains. They cover ordering, failure gates, exact-tree publication, atomic expected-absent reservation (including a same-base competing branch race), skipping or superseding the workflow's own open PRs (and leaving every other PR alone), skipped builds and publication when nothing changed, the SHA-pinned NuGet cache, API failure gates, default compatibility, stale bases, candidate drift, labels, and unrelated output exclusion without credentials, network, or running the legacy rebase loop. Structural tests verify Stage ownership, retained bootstrap and PR-template triggers, and that the effective changed-file set avoids organization-wide write triggers. CI supplies the PR base/pre-push SHA; local structural checks include staged, unstaged and untracked nonignored files against HEAD. They do not test live token policy, actual package availability, or downstream CI.
Update Cratis AI Profile Subscription prepares normal pull requests for
repositories that explicitly subscribe through .cratis/ai.json. It replaces
fleet corpus copying with exact package-version updates; it never auto-merges or
changes project context.
The controller:
- accepts only an immutable
Cratis/AI.Distributionrelease manifest URL and matching SHA-256; - verifies profile, public/engineering channel, package name, and exact SemVer;
- performs a dry run unless
applyand an exact repository confirmation are both supplied; - changes only
.cratis/ai.json, the matching exact Pi package source in.pi/settings.json, and the managed Pi runtime-state block in.gitignore; - preserves Pi skill filters and unrelated settings;
- rejects partial APM-managed updates until their lockfile can be refreshed;
- supports explicit rollback to a lower exact release;
- scopes the GitHub App token to one authorized repository; and
- opens a normal PR whose repository checks must pass before a human merges it.
When a subscriber selects Pi, an update also ensures this managed block in its
.gitignore (the dry-run receipt lists .gitignore among the changes and
reports piRuntimeIgnore as missing, incomplete or current; a rollback
leaves it alone):
# cratis-ai: pi runtime state
.ai-work/
.pi/delegate/
.pi/fusion/
.pi/tasks/
.pi/*-session-*/
# end cratis-ai: pi runtime state.ai-work/ is included only when no other line in .gitignore already ignores
it. Malformed (unmatched, nested or duplicate) managed markers are rejected before
any writes, preserving unrelated ignore rules. verify-no-work-records.yml fails
when these directories are tracked and the caller's path filters trigger it.
Run the controller locally without GitHub writes:
node .github/scripts/update-ai-profile-subscription.mjs \
--repository /path/to/subscriber \
--release-manifest /path/to/release-manifest.jsonAdd --apply only in a disposable/local checkout. The hosted workflow owns
branch and PR creation. Its one-time GitHub App installation scope and first
canary repositories remain tracked in #72 and #73.
The legacy Copilot synchronization system — all-to-all propagation, the
per-repository sync wrappers, and the corpus bootstrap — is retired.
Cratis repositories no longer carry a synchronized .ai/ corpus or generated
tool adapters.
Each repository instead commits the small, reviewed AI contract:
| File | Role |
|---|---|
.cratis/PROJECT.md |
the repository's own project context (facts, commands, conventions) |
.cratis/ai.json |
the profile subscription: which cratis/* profiles the repository expects |
AGENTS.md + CLAUDE.md/GEMINI.md |
minimal bootstraps pointing at .cratis/PROJECT.md |
Shared behavior arrives through the Cratis AI marketplace plugins (see the
harness guide); profile updates land as
reviewed pull requests through update-ai-profile-subscription.yml above.
General improvements are proposed in Cratis/AI, never synchronized
back from a consuming repository.
When a repository publishes Docker images or NuGet packages during pull requests (for example, pre-release builds tagged with the PR number), those packages should be removed once the pull request is closed to avoid accumulating stale artifacts.
Warning
Merging a change to .github/workflows/cleanup-pr-artifacts.yml into main
triggers Bootstrap Common Workflows, which can write directly to default
branches across the organization. Review and explicitly authorize that separate
deployment effect before merging. Do not dispatch bootstrap merely to test cleanup.
Existing callers using @main also adopt the changed implementation immediately.
The wrapper below can be added to an individually approved repository. Pin its reusable workflow reference to a reviewed commit when an immutable rollout is needed.
If you prefer to add the wrapper manually, create the following file in your repository:
.github/workflows/cleanup-pr-artifacts.yml
name: Cleanup PR Artifacts
on:
pull_request:
types: [closed]
jobs:
cleanup:
uses: Cratis/Workflows/.github/workflows/cleanup-pr-artifacts.yml@main
with:
pull_request: ${{ github.event.pull_request.number }}
secrets: inheritThe workflow assumes packages are tagged or versioned using the PR number:
| Package type | Expected pattern | Example |
|---|---|---|
| Container image (Docker) | Every tag matches ^pr{number}($|-) |
pr42, pr42-linux |
| NuGet package | Version matches (^|[.-])pr{number}([.-]|$) |
1.0.0-pr42.1 |
The boundary patterns preserve existing matching: pr42 never matches pr420.
A container version with both a matching tag and any other tag (including latest,
a stable tag, or another PR) fails preflight instead of deleting the shared digest.
Untagged/nonmatching versions and packages explicitly linked elsewhere are ignored.
A null repository association is unlinked and ignored; missing or malformed
association data fails closed rather than guessing ownership.
Only packages whose repository.full_name equals the calling repository are
eligible in reusable calls. Both container and NuGet inventories are completely
paginated and validated before any DELETE. The only listing-error exception is
HTTP 401/403 on the organization package inventory: reusable calls and dry runs
report a neutral warning and delete nothing, without treating it as an empty
inventory. Manual apply with an approved manifest fails instead. All other
listing errors, invalid JSON, malformed pagination, and identity drift fail the
job. A genuinely empty inventory succeeds with a zero count.
Every planned package association and version identity is read again during full preflight and immediately before deletion. Each DELETE must return 204 or 404, then a fresh package read must still confirm ownership, the version GET must return 404, and the active-version inventory must exclude the ID. Authentication errors are not waived. Other DELETE failures stop immediately, without blind retries. Readback mismatch makes the run red even if earlier deletes succeeded.
The program is inline in the reusable workflow: it neither checks out caller code
with the package token nor fetches a helper from a moving main reference.
Cleanup token: optionally create a classic PAT with read:packages and
delete:packages, package administration rights, and organization SSO
authorization if enforced. Store it as the organization secret
PACKAGE_CLEANUP_TOKEN, accessible to the calling repositories and Workflows for
manual runs. Cleanup uses it when provided; otherwise it falls back to
PAT_WORKFLOWS (still required by the reusable caller contract). Private
repository visibility may also require repo; manual apply additionally needs
read access to the target pull request. Fine-grained PATs do not support these
organization Packages endpoints.
The bootstrapped cleanup wrapper and the manual setup above use secrets: inherit,
so they automatically pass PACKAGE_CLEANUP_TOKEN when it is available to the
caller. Callers with an explicit secrets map must add
PACKAGE_CLEANUP_TOKEN: ${{ secrets.PACKAGE_CLEANUP_TOKEN }} to opt in; callers
that pass only PAT_WORKFLOWS are unaffected.
Permission failures remain blocking except for the organization inventory HTTP 401/403 warning described above and an HTTP 403 on one package's version inventory: cleanup warns, skips that package without deleting any of its versions, and cleans up the others. Manual apply fails instead when a package was skipped. HTTP 401 also covers an expired or revoked token: it now produces a warning in reusable cleanup calls and dry runs, but manual apply and other workflows using that secret still fail.
Dispatch Cleanup PR Artifacts in Cratis/Workflows, not the caller repository.
No reopen/close event is needed. One run addresses exactly one repository and PR;
there is no fleet mode. Manual runs are restricted to refs/heads/main and default
to dry_run: true. Before enabling manual use, configure the Workflows environment
pr-package-cleanup with required reviewers and a main-only deployment rule.
This workflow does not create or change environments or secrets.
| Manual input | Requirement |
|---|---|
target_repository |
Explicit Cratis/repository |
pull_request |
Positive integer |
dry_run |
Defaults to true; false requests apply |
confirmation |
Apply requires exactly DELETE Cratis/repository PR number |
expected_targets |
Apply requires the entire approved cleanup-plan.json, not just a count |
Example commands for a separately authorized operator (not run by tests):
# Read-only dry run, after the workflow has been deployed through review.
gh workflow run cleanup-pr-artifacts.yml --repo Cratis/Workflows --ref main \
-f target_repository=Cratis/Chronicle -f pull_request=42 -f dry_run=true
# Download the artifact from that exact dry-run ID, not the latest matching run.
gh run download DRY_RUN_ID --repo Cratis/Workflows \
-n cleanup-plan-DRY_RUN_ID-1 -D .ai-work/cleanup-pr42
# Only after reviewing/approving every target and retaining verified backups:
gh workflow run cleanup-pr-artifacts.yml --repo Cratis/Workflows --ref main \
-f target_repository=Cratis/Chronicle -f pull_request=42 -f dry_run=false \
-f 'confirmation=DELETE Cratis/Chronicle PR 42' \
-F expected_targets=@.ai-work/cleanup-pr42/cleanup-plan.jsonUse the actual run attempt in the artifact name. The machine-readable plan records
repository, PR, package types/names, numeric version IDs, version names/tags, and
DELETE/restore endpoints; it is printed as JSON and retained as a 30-day run
artifact, referenced in the run summary. It contains no credentials. Keep local
plans and backups in ignored .ai-work/ or approved secure storage, never Git.
Apply requires the PR to be closed, verifies its number and base repository, and compares a freshly completed inventory against the entire approved manifest. Any added, removed, renamed, or retagged target rejects apply before deleting. An empty approved manifest cannot authorize apply. There is no bypass input. The closed state is checked again before each deletion.
Recovery and limitations: Packages APIs are not transactional. Publication, retagging, repository transfers, PR reopening, and permissions can race the last check; coordinate/quiesce publishers before applying. A 404 alone does not prove deletion (GitHub may hide resources), hence the additional authenticated package and active-list readbacks. Inventories cover only what the token can see; a permission-filtered HTTP 200 cannot prove organization-wide visibility. Provision visibility for all packages linked to the target repository. Manual manifest comparison also rejects a visibility change that hides an approved target. API consistency delays conservatively fail the run. A partial failure does not roll back already-deleted versions. Inspect the run, obtain a new dry run and explicit approval for the remaining targets before retrying; do not reuse or silently trim an old approval. Restore endpoints are recovery metadata, not backups or a restoration guarantee: GitHub's restoration window, permissions, and name availability still apply. Retain verified package bytes before destructive recovery. No restore calls are made by this workflow.
The environment protects the manual job as configured, but repository/organization
secret availability and trusted workflow writers remain security boundaries.
Reusable automatic callers retain their existing pull_request + PAT_WORKFLOWS
contract and apply behavior, without requiring manual manifests or an environment.
They must invoke cleanup only for the intended closed PR.
python3 .github/scripts/tests/cleanup-pr-artifacts.test.py
actionlint .github/workflows/cleanup-pr-artifacts.yml \
.github/workflows/verify-workflow-programs.ymlThe regression suite extracts the actual inline production program and substitutes
an in-memory HTTP API. It checks Bash syntax and positive/negative inventory,
scoping, pagination, approval, deletion/readback, and redaction behavior without
GitHub credentials or network requests. Verify Workflow Programs runs it on
relevant pull requests and main pushes; it never invokes live cleanup.
For repositories using trusted publishing with npm and NuGet environments, the auto-approve-publish-deployments workflow automatically approves pending npm and nuget environment deployments when a publish workflow completes.
This workflow is distributed to all Cratis repositories via the common bootstrap process. It runs automatically whenever the Publish workflow finishes — no additional configuration is needed.
- When a
Publishworkflow completes (whether successful or not) - The
Auto-Approve Publish Deploymentsworkflow is triggered - It waits up to 30 minutes for pending npm/nuget deployments to appear
- Any pending deployments to
npmornugetenvironments are automatically approved - Publishing proceeds without manual intervention
This workflow is included in the common workflow bootstrap process and is automatically propagated to all Cratis repositories alongside other default workflows.
Note
See publish.template.yml for an example of a publish workflow that this auto-approve workflow will watch. Copy it to .github/workflows/publish.yml in the consuming repository and replace the placeholder package-publishing steps. It lives outside .github/workflows here because it is an incomplete example, not an executable workflow.
The template never cancels or drops a release. cratis/release-action releases one merged pull request per run, and GitHub's default concurrency keeps a single pending run per group and cancels any older pending one, so merges in quick succession would lose releases. Its concurrency group therefore uses queue: max, which keeps up to 100 pending runs and starts them in order, and leaves cancel-in-progress out (GitHub rejects it together with queue: max). A pull request closed without merging gets a group of its own, so it never takes a place in front of a real release. Keep this block when customizing the copy; verify-release-concurrency.yml checks it across the organization.
By default cratis/release-action releases every merged pull request on its own. A repository can opt in to batched releases instead: the publish workflow also runs on a schedule, and a run releases everything merged since the last release once the oldest pending merge is older than a window. Many small merges then become one release. A scheduled run with nothing pending, or a window that has not elapsed, ends in seconds without releasing. Behavior is unchanged until a repository opts in; the publish template carries the opt-in as a commented-out block (Stage is the pilot).
Two edits to publish.yml (the template shows both, commented out):
on:
schedule:
- cron: "6,21,36,51 * * * *" # minutes from the helper below
# ...
- uses: cratis/release-action@v1
with:
release-window: 60 # minutes; 0 (the default) releases per merge as beforeKeep the concurrency group constant (no per-run or per-commit key) and keep queue: max.
Repositories must not all tick at the same minutes. Run the helper with the repository name and use the line it prints:
.github/scripts/batched-release-cron.sh Chronicle
# 12,27,42,57 * * * *It derives an offset from 0 to 14 from the name (a checksum, like the weekly package-update schedule) and ticks four times an hour at the offset, +15, +30 and +45. Stage predates the helper and keeps its 7,22,37,52.
- A pull request labelled
hotfixreleases on its own merge push, without waiting for the window. - A manual
workflow_dispatchrun with the version left empty or0.0.0releases everything pending now. - If a hotfix merges while a
majorpull request is pending, the run started by the hotfix's own merge fails, because the pending major cannot ship inside a hotfix; the other runs only warn.
GitHub cron is best effort and can skip or lag. check-batched-releases.yml sweeps hourly and fails when a pending merge is older than the window plus 60 minutes.
Two composite actions replace hand-written actions/cache steps. Each gives a repository one key and one path list per ecosystem. That matters because the path list is part of the cache version: a cache saved by a step with one path list is never restored by a step with another, even under the same key.
Both follow the same rule:
- Pull requests only restore, through
actions/cache/restore. They never save, so they can no longer push main's caches out of GitHub's 10 GB repository limit. - Saves happen only on
pushto the default branch, onschedule, and onworkflow_dispatchagainst the default branch. The save runs at the end of the job, and is skipped when a cache for the exact key already exists. mode: restorenever saves andmode: savealways can; the defaultmode: autoapplies the rule above.
A pull request can only restore what main saved. Add a push-to-main or nightly job that calls the action, usually the build job you already run on push: main, so main always has a cache to restore.
Outputs: cache-hit (true only when the exact key was restored), matched-key (the restored key, empty on a miss), key and mode (restore or save, as resolved).
Caches NuGet packages only. Build output (obj) is not cached because it goes stale; reuse it within a run through a build-once artifact instead.
# after checkout and actions/setup-dotnet
- uses: Cratis/Workflows/.github/actions/dotnet-cache@main
- run: dotnet restore| Input | Default |
|---|---|
path |
~/.nuget/packages |
key-files |
**/packages.lock.json, **/*.props, **/*.targets, **/*.csproj, **/[Nn]u[Gg]et.[Cc]onfig, *.slnx, global.json (one per line) |
key-prefix |
nuget-v1 |
mode |
auto |
The key is <key-prefix>-<runner.os>-<hashFiles(key-files)>, with <key-prefix>-<runner.os>- as the restore key. The action fails when key-files matches no file, because a key without a content hash would make every run share one stale cache.
Caches Yarn's package cache. It never caches node_modules (Yarn rebuilds it from the package cache on install) or yarn.lock (a restore would overwrite the checked-out lockfile before yarn install --immutable validates it). It fails when working-directory has no yarn.lock.
Yarn 4 keeps its cache in a global folder (~/.yarn/berry/cache) unless enableGlobalCache: false, so by default the action caches the folder yarn config get cacheFolder reports in working-directory. That needs Yarn on PATH: run actions/setup-node and corepack enable before the action. Without Yarn, or when Yarn reports no folder (Yarn 1 prints undefined), it falls back to <working-directory>/.yarn/cache. An explicit path always wins and Yarn is not asked.
# after checkout, actions/setup-node and corepack enable
- uses: Cratis/Workflows/.github/actions/yarn-cache@main
with:
working-directory: Source/JavaScript
- run: yarn install --immutable
working-directory: Source/JavaScript| Input | Default |
|---|---|
working-directory |
. |
path |
Yarn's cacheFolder, else <working-directory>/.yarn/cache |
key-prefix |
yarn-v1 |
mode |
auto |
The key is <key-prefix>-<runner.os>-<hashFiles(<working-directory>/yarn.lock, <working-directory>/.yarnrc.yml)>, with <key-prefix>-<runner.os>- as the restore key.
verify-cache-actions.yml runs .github/scripts/tests/cache-actions.test.py (mode resolution, shared path and key, pinned actions) and calls both actions against .github/scripts/tests/fixtures/cache-actions/ under selftest-* prefixes, so the self-test never touches a real cache.
Cratis/Workflows/.github/actions/pulumi-guarded@main runs a Pulumi command, holds all of its output back and scans it for key, certificate and token material before anything reaches the log. On a match the step fails and the output is withheld; the annotation names the command and the kind of material, never the matched text. If the scan itself cannot run, the output is withheld too. A scan failure wins over Pulumi's own status; otherwise the step exits with Pulumi's status and the output is printed, so a failed run stays diagnosable.
pulumi/actions with command: streams output straight into the log and cannot be guarded, so replace those steps with this action. Keep pulumi/actions without command to install the CLI.
- uses: pulumi/actions@v5 # install only, no command
- uses: Cratis/Workflows/.github/actions/pulumi-guarded@main
with:
command: preview
stack-name: production
work-dir: Deployment
args: --diff --refresh| Input | Default |
|---|---|
command |
required (preview, up, refresh, destroy, stack, ...) |
stack-name |
none (--stack is omitted) |
work-dir |
. |
args |
none; extra flags, space-separated |
up adds --yes --skip-preview --non-interactive. Passphrase and backend credentials come from the step's environment, never from inputs.
The scan covers PEM private keys and certificates, kubeconfig credential fields, base64-encoded PEM armour, GitHub tokens, JWTs and AWS access key IDs. It cannot see unarmoured secrets, so mark every value that carries a credential as a Pulumi secret; a secret prints as [secret].
There are no exceptions and no allow-list: a certificate in the output, including an intentionally public CA certificate, withholds it. Fix a false positive at the source by marking that Pulumi output as a secret, or by not exporting it.
verify-pulumi-guarded.yml runs .github/scripts/tests/pulumi-guarded.test.py against a fake pulumi and calls the action in a self-test.
Cratis/Workflows/.github/actions/skip-if-already-ran@main reports skip=true when the same workflow already succeeded on the same commit for an event in events (default: schedule). Failed or cancelled runs do not count. Manual dispatches and manual re-runs always run; API errors warn and run normally. previous-run-url links to the prior success. See #205.
Place the step in an existing cheap gate job and expose its output, or add a small already-ran job:
already-ran:
runs-on: ubuntu-latest # Private repos: use cratis-arc / your RUNNER_GATE instead.
timeout-minutes: 5
permissions:
actions: read
outputs:
skip: ${{ steps.check.outputs.skip }}
steps:
- id: check
uses: Cratis/Workflows/.github/actions/skip-if-already-ran@main
build:
needs: [already-ran]
if: needs.already-ran.outputs.skip != 'true'
# ...existing job configuration...Every other job must directly list the gate in needs, and add needs.<gate>.outputs.skip != 'true' && (…existing condition…) to its if (or just the skip condition if it has none). This includes always(), !cancelled() and failure() aggregators and notification jobs; otherwise a deliberate skip can report red. Do not use the action in a reusable workflow with a scheduled caller: it would look up the caller's runs.
For workflows that push their own bot commit, pass sha: with the last relevant source commit rather than the bot-updated HEAD. The key excludes floating toolchains, runner images, repository variables and other external inputs: use this only to validate the repository's own commit, not live health, latest dependencies or live-data reports. External changes without a commit need a manual dispatch.
verify-pull-request-policy.yml combines release notes, warn-only release-note drift,
release intent and the work-record guard into one reusable job. A caller job named
policy produces the check policy / verify. Existing required-check settings
must be migrated explicitly when replacing older callers; bootstrap does not change
branch protection or rulesets.
The caller runs on pull-request events opened, edited, reopened, synchronize,
labeled, unlabeled, ready_for_review, and pushes to main, with one cancelling
concurrency group per PR/ref. Push events run only the work-record policy. Intent
applies only to PRs targeting main and can be disabled with release-intent: false.
When such a PR is opened without labels, the intent check waits 20 seconds before reading
them, so a label added right after opening cancels that run instead of leaving a stale
failure; verify-release-intent.yml does the same.
Notes retain the default-branch, release-label and Dependabot rules below. The
reusable declares no permissions; callers grant contents: read and
pull-requests: read. Releasing repositories also have a separate
dependabot-labels job with pull-requests: write, limited to Dependabot PRs into
main; policy runs even after skipped or failed normalization. The intent gate
itself never mutates labels and never fails Dependabot for its labels.
permissions:
contents: read
pull-requests: read
jobs:
policy:
uses: Cratis/Workflows/.github/workflows/verify-pull-request-policy.yml@main
with:
runs-on: ${{ vars.RUNNER_GATE || 'ubuntu-latest' }}
release-intent: false # A repository that does not releaseThis sample shows only the gate job. A complete caller also declares the triggers and
the concurrency group above and, in a releasing repository, the dependabot-labels
job (with policy declaring needs: dependabot-labels and if: ${{ !cancelled() }}).
Notes and intent run only for pull_request events; a caller on another event gets
only the work-record policy.
The merge-tree checkout uses persist-credentials: false and is scanned by the
same commit- and SHA-256-pinned work-record guard. extra-allowed and
extra-allowed-paths retain the guard's meanings below. Drift fetches the PR head
and base into the shared repository and uses a separate detached worktree so its
unchanged program sees the PR head as HEAD, not the merge commit. Fetch or
worktree failure warns without failing policy. Each blocking gate runs with its
own recorded outcome; the final step fails on a failed, unexpectedly skipped or
missing gate, including guard exit 2 (could not run), and writes a per-gate summary.
The job checks node, git and curl availability and has a ten-minute timeout.
verify-release-notes.yml, verify-release-intent.yml and
verify-no-work-records.yml remain unchanged and available for pinned or
hand-maintained callers. The older release gates behave as follows:
verify-release-intent.yml(called by each repository'sverify-semver-label.yml) requires exactly one ofmajor,minor,patchorno-release, and onlyno-releaseon a Dependabot pull request: Dependabot appliesmajor/minor/patchfrom the dependency's own version, and release-action never releases its pull requests. It never fails a Dependabot pull request for its labels: it replacesmajor/minor/patchwithno-releaseitself when its token can write, and passes with a notice when it cannot, so callers that predate the normalizer (the repositories the bootstrap ignores) keep working.normalize-dependabot-labels.ymldoes the same ahead of the gate in the bootstrapped caller, whose gate job only haspull-requests: read.verify-release-notes.ymlchecks the pull request description, because release-action publishes it verbatim as the GitHub release and closes every issue written as(#n)outside code.
Both gates read the labels (and the release-notes gate the description, author and base) from the pull request as it is now, not from the event payload, so "Re-run" sees the current state instead of replaying the event that started the run. A caller grants pull-requests: read for that read; without it the gates fall back to the payload with a notice. The reusable workflows set no permissions of their own on purpose: a called workflow can only narrow its caller's grant.
The release-notes gate checks every pull request into the default branch except Dependabot's. One that carries any of major, minor or patch is release-bound and fails on a violation, also when it carries a second intent label by mistake. One labelled no-release, or not labelled yet, gets the same checks as warnings and passes, since any pull request may become releasable; a no-release description without a change list is fine. A pull request into another base passes with a notice. The check re-runs when a label is added or the description is edited. It enforces the release-note contract:
- An optional summary, first: either a short
## Summarysection of prose or one lead paragraph without a heading, not both. Then only## Added,## Changed,## Fixed,## Removed,## Securityand## Deprecated, in that order, each once and only when it has bullets: prose or a code example alone under a section, such as aNone.filler, fails, and so does a bullet that only saysNone,N/AorNothing(also in bold or italics, with closing punctuation), which is a filler and not a change, so a section holding only such bullets is a section without bullets.###sub-headings may group bullets inside a section (there even### Addedis only a sub-heading; before or between sections a###named like a section fails as a wrong-level section). - A delivered issue is
(#n)at the end of its bullet (only more issue references, including(part of #n),(see #n)and(Cratis/Repo#n), closing emphasis,<br>and sentence punctuation may follow; a:may introduce nested bullets). Write one(#n)per issue:(#56) (#57), never(#56, #57), which closes nothing.(part of #n)orsee #nkeeps an issue open, andCratis/Repo#nnames another repository.Closes,Fixes,Refsand the other closing or linking keywords never appear, with#nor an issue URL, also when wrapped in bold, italics or a link (**Closes** #n,Closes [#n](url),[Closes #n](url), and[Closes](url)where the link starts a clause). A(#n)in the summary, in a heading, in the middle of a bullet or inside an HTML comment fails: in the summary, a heading or the middle of a bullet release-action would still close it, and inside a comment release-action removes it with the comment, so the issue is not closed. For a(#n)in the middle of a bullet the error quotes the text that follows it ((#12)is followed by, see #11 ...) and asks for that text to move before the(#n). - No Overview, Test plan, Verification, Notes or other headings (also written as
<h2>,<b>,<i>,*italic*or<summary>, which may span lines), no review, verification, test-result or provenance notes (which agent or model wrote it, a stand-aloneReviewed byline, CI results), no relative links, no template placeholders, no unclosed<!--and no Copilot "Original prompt" transcripts. Headings are checked like any other line for keywords,(#n), relative links, placeholders and provenance. Provenance is recognised at the start of a clause (Reviewed with a cross-provider review.,Review: Opus-only review,The review workflow passed.,Co-Authored-By:); inside a bullet, a## Summaryor the lead paragraph the note must also end the clause (- Cross-provider review pending (#3)), andReviewed by ...is not read there at all (- Reviewed by status is now shown on the dashboard (#4)passes), so a bullet that merely mentions review or validation as a product change, such as- Validation: rules now apply to commands (#3)or- Adds same-provider review routing to the review workflow (#3), passes. Reviewer-facing information goes in a pull request comment. - No verification or scope notes and no reviewer instructions, recognised by their phrasing wherever they stand (a bullet, the summary, the lead paragraph or a line of their own): how or where a change was checked (
Verified by building ...,verified in Storybook,Tested locally), what was not (not yet verified,Storybook only), results (specs 492/492,9 of 9 runs,Tier 1 PASS,0 warnings, 0 errors, aLocal:,CI:,Checks:orChecked locally:line, aChecks (local)heading), provenance (Verified against Arc.TypeScript main (v0.34.0),Tested against a real kernel; the error asks for compatibility as a fact instead, such asRequires @cratis/arc 0.34 or later), and stacking, retargeting, merge or deploy order and draft status (Stacked on #207,Retarget to main,This PR should be deployed separately,Draft:,Do not merge), reported as a reviewer instruction. The patterns name the phrasing, not the topic:Events are now verified against their schema,Toasts are stacked on top of each otherand- CI: builds now cache packages (#3)pass.
Fenced code and inline code are exempt. Closed HTML comments are not shown on the release page, so the heading, keyword, link and placeholder rules ignore them, and release-action removes them before publishing, so a (#n) inside a comment closes nothing. Such a (#n) still fails: write it at the end of the delivering bullet, outside the comment, and never put issue references in comments. Each violation is reported as its own error, naming the rule, the offending line and the fix. Whether a summary states a cohesive theme, whether a bullet is user-facing and whether an issue number exists cannot be decided from text; reviewers keep those.
In the legacy release-notes reusable, a second job, drift, compares the notes with the diff and only ever warns: a bullet whose code spans (paths and identifiers) the diff no longer touches, because the change landed on main another way or was dropped in conflict resolution; a version the diff only removes; a registry (PyPI, npm, NuGet, Maven Central, Docker Hub, GHCR) that no workflow publishes to; and a merge from the base branch that changed which files the pull request touches. It checks out the pull request head with full history (blobs on demand), skips Dependabot, and runs on label changes too, because the caller's concurrency group cancels the whole run when a label changes and the check would otherwise stay cancelled.
A repository calls the gate with a job named release-notes, so the checks read release-notes / verify and release-notes / drift and do not collide with other verify / verify gates. The caller runs on opened, edited, reopened, synchronize, labeled, unlabeled and ready_for_review:
permissions:
contents: read
pull-requests: read
jobs:
release-notes:
uses: Cratis/Workflows/.github/workflows/verify-release-notes.yml@mainThe release-intent caller names its job release-intent, so its check reads release-intent / verify rather than verify / verify, and corrects a Dependabot pull request's labels before the gate:
permissions:
contents: read
jobs:
dependabot-labels:
if: github.event.pull_request.user.login == 'dependabot[bot]'
uses: Cratis/Workflows/.github/workflows/normalize-dependabot-labels.yml@main
permissions:
pull-requests: write
release-intent:
needs: dependabot-labels
if: ${{ !cancelled() }}
uses: Cratis/Workflows/.github/workflows/verify-release-intent.yml@main
permissions:
contents: read
pull-requests: readThe optional runs-on input (default ubuntu-latest) names the runner label, as in verify-no-work-records.yml; a caller in a private repository passes its self-hosted scale-set label so the gate does not use billed GitHub-hosted minutes:
release-notes:
uses: Cratis/Workflows/.github/workflows/verify-release-notes.yml@main
with:
runs-on: cratis-arcThe pull request template's comment states the same contract; it is propagated to every repository that is not in the template propagation's ignore list, including those where the check is not installed, so it says editing the description re-runs the check "where it is installed". Its placeholder bullets carry no (#n): the comment shows the syntax, and a description that only fills in the placeholders must not arrive with a literal (#123).
The common workflow bootstrap now installs verify-pull-request-policy.yml in every
non-archived Cratis repository outside REPOS_TO_IGNORE. It removes each old
verify-release-notes.yml, verify-semver-label.yml and verify-no-work-records.yml
caller only when its blob matches a known historical bootstrap payload for that
path; customized callers are kept and logged. Replacement and deletions share one
Git tree and commit, and a failed replacement aborts the update. Ref updates remain
nonforce; a non-fast-forward retries once after re-reading the fresh HEAD and files.
An old semver caller, an existing policy caller with release-intent: true, or a
workflow using Cratis/release-action enables intent and the normalizer. Detection
errors skip the repository rather than installing a nonreleasing caller. Repositories
that never release receive release-intent: false; Arc.Kotlin also receives false
because it retains its pinned semver caller. The canonical policy caller passes
runs-on: ${{ vars.RUNNER_GATE || 'ubuntu-latest' }} to the gate. The bootstrap reads each repository's visibility and renders that default per repository: a public repository gets ubuntu-latest (its pull_request jobs never run on self-hosted runners, per .cratis/ai/rules/github-actions.md), a private repository gets cratis-arc-small, also for the Dependabot label normalizer, and a repository whose visibility cannot be read is skipped. RUNNER_GATE is an outage escape hatch, normally unset: a public repository sets it temporarily to reroute the job when the default runner is unavailable, and deletes it afterwards. It is not how a repository chooses its runner permanently.
For repositories that receive the release-intent gate, the bootstrap also creates any missing major, minor, patch and no-release labels, even when workflow files are already up to date. Existing labels are matched case-insensitively and are never edited, renamed, recolored or deleted. Repositories that do not receive the release-intent gate get no labels.
A repository that keeps its own copy of one bootstrapped file, and takes every other one, is listed for that file in the script's SKIP_FILE_REPOS: Arc.Kotlin keeps its SHA-pinned verify-semver-label.yml.
Repositories in REPOS_TO_IGNORE never receive the callers automatically and need them added through their own reviewed change. That covers the repositories that hold their own governance (AI, Templates, release-action, Stage, Dockerfiles, and Chronicle.Dapr and Chronicle.Wolverine once they release), the private repositories (Studio, Direct, Ensemble, Infrastructure, Experiments, Strategy, Identity, KeldaKraft, studio-cluster-verification) and the public repositories that customize update-packages.yml on purpose (Ante, Chronicle.Elixir, Components, Orleans). Private repositories have no GitHub-hosted Actions budget and run their wrappers on cratis-arc, so the bootstrap must not overwrite those wrappers with ubuntu-latest ones, and a repository that removed a wrapper on purpose must not get it back. Such a repository copies the canonical caller and gives the gate its own runner fallback, in the same shape as its other wrappers: runs-on: ${{ vars.RUNNER_GATE || 'cratis-arc' }}, or, in Ensemble, runs-on: ${{ vars.ENSEMBLE_RUNNER || 'cratis-arc' }}. It does not set RUNNER_GATE permanently. A new private repository, or a new repository with a deliberate customization, is added to REPOS_TO_IGNORE with the reason in the comment beside it (the bootstrap's spec asserts that the private repositories and the customized ones stay listed). AI.Distribution is no longer in use and is ignored by both the bootstrap and template propagation. Arc.TypeScript, Chronicle.Python and the two Eventmodelers build kits were never bootstrapped, so a run would also install every standard wrapper there for the first time; they are held back until that is decided.
The policy specs execute the combined inline programs, verify that the live-read, notes and drift copies remain byte-identical to the legacy reusable, and cover head-versus-merge drift, fetch failure and blocking aggregation. Offline bootstrap specs cover atomic migration, customizations, failed replacement/discovery, idempotence, release continuity and the bounded non-fast-forward retry.
The check's program is inline in the reusable workflow, so the rules that run are exactly the ones at the ref the caller names. verify-release-notes-program.yml runs .github/scripts/tests/verify-release-notes.spec.mjs against that exact program, including a corpus of over 900 published Cratis release bodies (.github/scripts/tests/fixtures/release-notes/corpus.json) whose recorded rules any rule change must keep or deliberately update, and .github/scripts/tests/verify-release-intent.spec.mjs against the release-intent gate and the Dependabot normalizer.
smoke-workflows.yml runs on pull_request changes to workflows, actions,
templates, the actionlint/zizmor configurations and installers, and their tests and fixtures.
It calls reusable workflows through ./.github/workflows/ so GitHub uses the
PR merge commit, not the version on main. The lint and public-runner calls also
pass ${{ github.sha }} as workflows-ref for their supporting files. Permissions
are read-only, no secrets are passed, and public PRs use ubuntu-latest.
The path filter is intentional: this workflow is not a required check.
| Coverage | Reusable workflow | Limit |
|---|---|---|
| Smoke call | workflow-lint.yml |
All workflows plus the publish template; shellcheck enabled; zizmor in report mode |
| Smoke call | docker-cleanup-gate.yml |
Read-only scan; its checker checkout still defaults to main |
| Smoke call | check-public-runners.yml |
Read-only scan using the PR merge commit's checker |
| Smoke call | verify-no-work-records.yml |
Uses its existing immutable script pin |
| Smoke call | verify-discoverability.yml |
Good HTML fixture at https://example.cratis.io; existing script pin |
| Not smokeable | update-packages.yml, cleanup-pr-artifacts.yml |
Secrets and repository writes/deletions |
| Not smokeable | trigger-documentation-build.yml |
Cross-repository dispatch and a secret |
| Not smokeable | normalize-dependabot-labels.yml |
Writes labels |
| Not smokeable | verify-pull-request-policy.yml, verify-release-notes.yml, verify-release-intent.yml |
Holds Workflows PRs to consumer release policy; decide separately |
auto-approve-publish-deployments.yml uses workflow_run, not workflow_call,
so it is outside the reusable coverage ratchet. Action changes trigger this
workflow, but these smoke calls do not execute composite actions. The offline test
requires every reusable workflow to be called here or have an explicit reason
in NOT_SMOKEABLE; adding a reusable without either fails the test.
The shared installer pins actionlint 1.7.12 and verifies the Linux amd64/arm64
archive checksum. workflow-lint.yml accepts runs-on (default ubuntu-latest),
workflows-ref (default main), shellcheck (default false), zizmor (default
report), and extra-files (a space-separated list of additional paths). It always lints all caller workflows,
including when extra files are supplied. It prefers the caller's
.github/actionlint.yaml or .github/actionlint.yml, otherwise uses the shared
configuration. The shared config recognizes Cratis runner labels and ignores
only actionlint's unsupported concurrency.queue key under workflows/templates;
remove that exception when actionlint supports it. Pyflakes is disabled.
The zizmor installer pins 1.31.1 and verifies the Linux amd64/arm64 release
archive SHA-256 before extraction. The security audit runs with --offline:
no GitHub token or secrets are needed, and online-only audits are not run.
The zizmor input selects one of three modes:
report(default): print findings without failing on them; tool errors still fail.blocking: findings fail the job using zizmor's normal exit codes.off: skip installing and running zizmor (actionlint still runs).
Zizmor uses its native caller configuration discovery: .github/zizmor.yml,
.github/zizmor.yaml, zizmor.yml, then zizmor.yaml. Without a caller config,
it uses zizmor's built-in defaults; no shared suppressions are applied. It audits
all caller workflows and, when present, composite actions under .github/actions;
extra-files applies only to actionlint. Start with report mode, fix or narrowly
justify findings in the caller's config, then set
zizmor: blocking on the reusable workflow call once the offline audit is clean.
Workflows' own smoke call remains in report mode while existing findings are triaged.
For local checks, install actionlint, zizmor, shellcheck, and Python's PyYAML:
actionlint
actionlint .github/templates/publish.template.yml
zizmor --offline .github/workflows .github/actions
python3 -B .github/scripts/tests/workflow-lint.test.pyThe test proves a broken workflow fails with expression, job-needs and
syntax-check rule IDs, and that queue: max passes with the shared config.
It also proves zizmor detects unpinned-uses and template-injection, blocking
mode fails, report mode prints the findings without failing, and caller config
is honored. The composite fixture is caught, and callers without .github/actions
or with an empty actions directory pass.
For a reusable that can safely be dispatched with its existing permissions,
use a scratch branch in a consumer such as Arc. Point its caller at
Cratis/Workflows/.github/workflows/<file>@<your-branch>, ensure that caller
supports workflow_dispatch, push the scratch branch, then run:
gh workflow run <caller-file> --repo Cratis/Arc --ref <scratch-branch>Dispatch is not a dry run: use only explicitly authorized secrets and effects.
Link the exact run in a PR comment, not the release-note description.
Cleanup is an exception: its manual dispatch is main-only and dry_run is not a
reusable input. Do not dispatch a scratch cleanup caller (it deletes artifacts).
After the reviewed change lands, use dry_run: true in Workflows following
Retry an already-closed PR safely; do not
weaken that main-only boundary to test a branch.
For consumer pull_request_target or publish workflows, use actionlint only
before merge and watch the first authorized run on main. Smoke calls do not
prove privileged event semantics, publishing, deployments, or secret-backed
writes, and are not a consumer rollout or a bootstrap installation.
Triggers: workflow_call for closed-PR callers; workflow_dispatch in Workflows
for an explicitly targeted manual dry run or approved retry.
Deletes only exactly matching, repository-associated container/NuGet versions after
complete preflight, and verifies their absence. HTTP 401/403 from the organization
package inventory reports could not run: package inventory not authorized as a
neutral warning and deletes nothing for reusable calls, scheduled runs and dry runs;
a manual apply with an approved manifest fails instead. This is not a completed
cleanup. The optional secret PACKAGE_CLEANUP_TOKEN (classic PAT with
read:packages and delete:packages, package admin, SSO-authorized) takes
precedence over PAT_WORKFLOWS. All other errors still fail closed. The reusable
contract remains the required numeric pull_request input and PAT_WORKFLOWS
secret, plus the optional PACKAGE_CLEANUP_TOKEN secret. See
Cleaning up PR artifacts for matching rules, permissions,
manual approval inputs, recovery limitations, and offline verification.
A reusable guard that fails when AI session work records are tracked. It applies five rules:
| Rule | Fails when tracked |
|---|---|
ai-work |
anything under .ai-work/ |
pi-runtime |
anything under .pi/delegate/, .pi/fusion/, .pi/tasks/ or .pi/*-session-*/; .pi/settings.json and .pi/extensions/ stay allowed |
root-document |
a root-level SCREAMING-CASE .md file outside the root allowlist (README, LICENSE, AGENTS, CLAUDE, GEMINI, CODE_OF_CONDUCT, CONTRIBUTING, SECURITY, CHANGELOG, CREDITS, RESOURCES, BRAND, MESSAGING, PAGES, SITE, PRIVACY_POLICY, ROADMAP, START-HERE, CHRONICLE, COMPATIBILITY, NOTICE, SUPPORT, GOVERNANCE, VERSION, DECISIONS) |
session |
explicit session prefixes at any depth, case-insensitively (Handover-*, PROMPT-*, NEXT-SESSION, Session-*, with - or _ separators); legacy embedded all-caps names such as HANDOVER.md are also caught; never exempted by a documentation directory. Bare prompt.md, session.md and handover.md remain ordinary product files |
work-record-shape |
a PLAN, DESIGN, REPORT or STATUS file at any depth (PLAN-foo.md, Plan-foo.md, Status.md), outside the documentation directories and product templates/ directories |
Decision records are documentation. decisions/, Documentation/, docs/, Knowledge/, evidence/ and governance/ at the repository root exempt the work-record-shape rule, case-insensitively. Product templates/ directories at any depth also exempt the shape rule (for example Direct's prompt sections), not the session/runtime rules. Files under .claude/, .github/, .pi/, .agents/ and .cratis/ are skipped by the name rules. Third-party trees under a directory named exactly vendor/, at any depth, are skipped by the name rules too, so vendored files keep their upstream names (for example vendor/claude-seo/references/prompt-engineering.md); the .ai-work/ and .pi/ runtime rules still apply. Work-record prefixes match every letter case, including pLaN-notes.md and design-notes.md, outside the documentation directories.
Inputs:
| Input | Default | Meaning |
|---|---|---|
runs-on |
ubuntu-latest |
Runner label |
extra-allowed |
empty | Comma-separated additional root-level file names, with or without .md; exempts the root-document and work-record-shape rules, not session/runtime rules |
extra-allowed-paths |
empty | Comma-separated additional documentation directory prefixes, such as product-docs/ |
Existing @main callers retain their root exemptions. evidence/ and governance/
are documentation by default, so Strategy's reviewed documents require no coordinated
new-input rollout. Other documentation directories can use extra-allowed-paths
after the reusable workflow exposing that input is available.
Callers must include .pi/** in both pull_request.paths and push.paths for the
pi-runtime rule to run on runtime-only changes; updating the reusable workflow
alone does not update caller triggers. Keep their branch filters, runner and other
settings unchanged. For each event's paths, use:
paths: ["**.md", ".ai-work/**", ".pi/**"]Without .pi/**, runtime-only changes do not run the guard. Updating the bootstrap
caller template is an organization-wide rollout and is reviewed separately; no
fleet-wide writes are performed by this change or by the checker itself.
Exit codes: 0 ran and found nothing, 1 ran and found violations (one line per file with the rule id), 2 could not run (git ls-files failed or listed no files). A green run prints scanned: <n> tracked files, rules: 5, violations: 0.
The checker is .github/scripts/verify-no-work-records.mjs. The reusable workflow downloads it from an immutable Workflows commit into the runner's temporary directory and runs it over the caller's checkout; download or prerequisite failure exits 2. The downloaded script's SHA-256 must match the workflow's reviewed checksum before execution. When changing the script, commit it first and update the workflow's script pin and checksum. Offline tests compare the checksum with the tested source without needing the script commit in a shallow or squash-merged checkout.
Run node .github/scripts/verify-no-work-records.mjs --self-test to plant one defect per rule in temporary repositories and check the exit codes and clean counts; VERIFY_SELF_TEST_BREAK=1 makes the self-test fail. verify-workflow-programs.yml runs .github/scripts/tests/verify-no-work-records.test.py on every guard change, including the self-test, mixed-case fixtures, NUL-delimited filenames, listing failures and the pinned workflow wrapper.
A reusable check that reads a built static site and reports search (SEO), answer-engine (AEO) and generative (GEO) discoverability findings in the job summary and as workflow annotations. It is warn-only by default; set fail-on: error to fail the job on error-level findings.
Per HTML page (pages with noindex or a meta-refresh redirect are skipped):
| Check | Level |
|---|---|
<html lang> present |
error |
exactly one non-empty <title>; more than 60 characters |
error; warning |
| meta description present; more than 160 characters | warning |
<link rel="canonical"> is an absolute URL on the base-url host |
error |
every application/ld+json block parses as JSON |
error |
og:image present, with og:image:alt |
warning |
Per site: robots.txt exists and has a Sitemap: line (error); sitemap.xml or sitemap-index.xml exists, and every URL in it (following sitemap indexes) is on the base-url host and maps to a built page (error); llms.txt exists (warning).
Inputs:
| Input | Default | Meaning |
|---|---|---|
site-path |
required | Directory holding the built site, relative to the repository root |
base-url |
required | Production origin, for example https://blog.cratis.io |
fail-on |
none |
none reports only; error fails when any error-level finding exists |
ignore |
empty | Comma- or newline-separated globs relative to site-path (* and ? stay in one segment, ** crosses directories), for example 404.html,drafts/** |
build-command |
empty | Command that builds the site into site-path; leave empty for plain-HTML or prebuilt sites |
node-version |
24 |
Node.js version for the build and the check |
runs-on |
ubuntu-latest |
Runner label; private repositories pass their scale-set label |
Caller:
name: Verify Discoverability
on:
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
discoverability:
uses: Cratis/Workflows/.github/workflows/verify-discoverability.yml@main
with:
site-path: dist
base-url: https://blog.cratis.io
build-command: npm ci && npm run build
ignore: 404.html
# fail-on: error # once the site is cleanExit codes of the checker: 0 no failing findings, 1 errors with fail-on: error, 2 could not run (bad arguments, missing site directory, download or checksum failure).
The checker is .github/scripts/verify-discoverability.mjs, Node built-ins only. Like the work-record guard, the reusable workflow downloads it from an immutable Workflows commit and verifies its SHA-256 before running it; when changing the script, commit it first and update the workflow's pin and checksum. verify-discoverability-check.yml runs .github/scripts/tests/verify-discoverability.test.py on every change: it checks a good and a bad fixture site (.github/scripts/tests/fixtures/discoverability/), the summary output, fail-on, ignore globs, the exit codes, and the pinned download step.
Triggers: pull_request and push to main when the checker, its tests and
fixtures, the publish template or the workflow change; weekly schedule; and
workflow_dispatch.
Fails when a Cratis publish, release or deploy workflow can cancel a release or drop
a pending one. Pull requests and pushes run
.github/scripts/tests/verify-release-concurrency.test.py (fixtures and the publish
template) and the checker's --self-test. The schedule and manual runs sweep every
non-archived Cratis repository's default branch with PAT_WORKFLOWS (read only).
The checker, .github/scripts/verify-release-concurrency.py, looks at workflow files
whose name contains publish, release or deploy. It skips verify-*,
auto-approve-* and names containing release-notes, release-intent or
documentation (a documentation workflow only triggers a site build, where the newest
build covers the ones before it). A file whose on: lists workflow_call and no other
trigger is exempt from every check, because it can only run as a reusable piece inside
its caller's group; a file that also has its own trigger (for example workflow_call
plus workflow_dispatch) is checked. For each remaining file:
- any
cancel-in-progressat workflow or job level that is not literallyfalseis a violation, including an expression; - a publish or release workflow (name not starting with
deploy) must queue its pending runs. GitHub applies the workflow-level group before any job group, so a workflow-level concurrency block needsqueue: maxitself, unless its group is unique per run or commit (it containsgithub.run_idorgithub.sha); then a job's concurrency needsqueue: max. Without a workflow-level block, a job's concurrency needsqueue: max. Other job groups without a queue (for example a latest-wins deploy job) are fine. Deploy workflows need no queue: deploying the newest commit covers the ones before it.
A workflow that deliberately keeps a single pending slot is listed in the script's
ALLOWLIST with its repository, file and reason (today: Studio's publish.yml, which
releases every unreleased pull request in one run, and Arc's chronicle-published.yml,
a Chronicle dependency bump with one group per version that releases nothing itself). The exemption covers only the
missing queue: max, never cancelling, and the sweep fails an entry once its file no
longer needs it.
Run it with --dir <workflows directory> [--repo <name>], --org (needs GH_TOKEN) or
--self-test. Exit codes: 0 ran clean, 1 found violations (one
<repository>/<file>: <reason> line each), 2 could not run (GitHub API error, no
repositories, no workflow files or release subjects to check, or a release workflow
that does not parse). A clean run prints
checked <n> repositories, <m> workflow files, <k> release subjects, 0 violations.
Triggers: pull_request and push to main when the check, the cron helper, their
tests, the publish template or the workflow change; hourly schedule; and
workflow_dispatch.
Fails when a repository that releases in batches has releasable
merges waiting too long. Pull requests and pushes run
.github/scripts/tests/batched-releases.test.py (the template's opt-in block, the cron
helper and the staleness decision) and the check's --self-test. The schedule and manual
runs sweep every non-archived Cratis repository with PAT_WORKFLOWS (read only, no
writes or dispatches).
The check, .github/scripts/check-batched-releases.py, looks for repositories whose
default-branch .github/workflows/publish.yml sets release-window: above 0. For each it
lists the pull requests labelled major, minor or patch merged after the latest
non-draft release. If the oldest is older than the window plus 60 minutes it prints an
::error:: naming the repository, the pull requests and the age; otherwise it prints one
summary line. Exit codes: 0 ran clean, 1 found a stale repository, 2 could not run
(GitHub API error, or no repository opts in, so nothing was checked).
Fails when a public repository's pull_request or pull_request_target job resolves to a
self-hosted runner (self-hosted, cratis-arc*, or a runner group), because any fork could then
run code on our infrastructure. The checker, .github/scripts/verify-public-runners.py, follows
literal runs-on, ${{ vars.X || 'label' }} and ${{ inputs.X || 'label' }} fallbacks, matrix
lists and reusable workflows (./ and Cratis/<repo>/...@ref) with the caller's with: inputs and
the callee's input defaults. An expression it cannot resolve is reported as unresolved runner and
fails: unknown is not pass. Private repositories are never reported.
verify-public-runners.yml runs the checker's tests (fixtures, the bootstrap's rendered public
callers, this repository's own workflows) and --self-test on pull requests and pushes, and sweeps
every public Cratis repository weekly with PAT_WORKFLOWS, also treating a set variable such as
RUNNER_GATE that points at a self-hosted label as a finding. Other repositories can call
check-public-runners.yml@main, which checks their own workflows when the repository is public.
The reusable gate (--dir mode) does not read repository, organization or environment variables:
it treats vars.X as unset and uses the fallback, so a set RUNNER_GATE is caught only by the weekly
sweep (which reads repository and organization variables, not environment-level ones). Set
workflows-ref to the same ref the caller pins for check-public-runners.yml, so the checker and the
reusable workflows it follows match.
Run it with --dir <workflows directory> [--repo <name>] [--visibility public|private] [--local-repo Workflows=<directory>] [--fetch-remote], --org (needs GH_TOKEN) or --self-test.
Exit codes: 0 ran clean, 1 found violations, 2 could not run.
Triggers: pull_request and push to main when the checker, its tests and
fixtures, the workflow or docker-cleanup-gate.yml change; weekly schedule; and
workflow_dispatch.
Fails on Docker cleanup that is not scoped to a run. Each cratis-arc job has its own
Docker daemon, but an unscoped prune inside a job still removes containers that parallel
steps or Testcontainers in the same job need (Cratis/StudioIssues#905). Pull requests and
pushes run .github/scripts/tests/verify-docker-cleanup.test.py and the checker's
--self-test. The schedule and manual runs clone every non-archived Cratis repository
shallowly with PAT_WORKFLOWS (used read-only; the token itself has write access) and scan it.
The checker, .github/scripts/verify-docker-cleanup.py, reads workflow files, composite
actions, .sh .bash .ps1 .py .mjs .js .ts .cs scripts, Dockerfile*, Makefile and
justfile by logical line (comments ignored). It fails on docker system|container|image| volume|network|builder|buildx prune without --filter label=..., and on docker rm|kill| stop|rmi (or volume rm, network rm) fed by $(docker ps|ls|images ...) or
| xargs without --filter label=... on the listing. label!= and until= do not scope
a cleanup. A deliberate exception goes in the script's ALLOWLIST with its reason; the
sweep fails an entry once its file no longer has a violation.
Another repository gates its own checkout by calling the reusable
docker-cleanup-gate.yml (a reusable workflow sees the caller's event, so the gate is its
own file, with only a workflow_call trigger). The caller names its job docker-cleanup,
so the check reads docker-cleanup / gate:
on:
pull_request:
permissions:
contents: read
jobs:
docker-cleanup:
uses: Cratis/Workflows/.github/workflows/docker-cleanup-gate.yml@mainThe gate checks out the calling repository and Cratis/Workflows (into
.cratis-workflows, excluded from the scan) and runs the checker with --root .; the
optional runs-on input (default ubuntu-latest) names the runner label. Exit codes: 0 clean, 1 violations
(<repository>:<path>:<line>: <reason> each), 2 could not run (a repository that cannot
be cloned, no repositories, or no files to scan). A clean run prints
Scanned <n> files in <m> repositories, found 0 violations.
Triggers: push to main when a watched reusable workflow (including
cleanup-pr-artifacts.yml) or bootstrap script changes; also
workflow_dispatch.
Maintains common wrappers across non-archived Cratis repositories, subject to its
REPOS_TO_IGNORE list. This is an organization-wide write workflow, including
direct default-branch updates, not an offline check or an automatically authorized
cleanup rollout. Its PAT requires the repository/workflow write permissions and
branch-rule bypass described in the workflow. Exact deployment effects need
separate approval before merging a watched file.
A run does not start every push-triggered workflow in every repository at once (#163):
- Canary. The first repository that needs a change is pushed normally and keeps
its CI run. Repositories named in
CANARY_PREFERENCE(defaultFundamentals) are tried first, so the canary is the first of them that needs a change, or else the first repository that needs one. Before pushing it, the run checks that the PAT can list that repository's Actions runs and stops with exit code 1, before any push, when it cannot. The run then waits for the workflow runs GitHub starts for the canary's commit. They pass only onceCANARY_SETTLE_SECONDShave elapsed since the push and two consecutive reads show the same completed runs, because GitHub creates a commit's runs over several seconds. It stops with exit code 1, before any other repository is changed, when one of them ends with a conclusion other thansuccess,skippedorneutral, when they are not complete afterCANARY_TIMEOUT_SECONDS(default 2700), or when the runs cannot be read three times in a row. A canary that starts no run withinCANARY_SETTLE_SECONDS(default 300) is logged and the rollout continues. Reading the runs needs the PAT's Actions: Read permission. [skip ci]on the rest. Every later bootstrap commit touches only the bootstrapped wrapper files and ends with[skip ci], an approved exception to the rule against[skip ci]on bot commits: the canary's CI run is the check that sees a merged result. Package-update and other bot commits are unchanged.- Stagger. After each push, the next repository waits
STAGGER_SECONDS(default 20) before its HEAD is read. A run with nothing to change waits for no canary.
The workflow sets none of these, so the script's defaults apply; they are
environment variables of .github/scripts/bootstrap-common-workflows.sh.
python3 -B .github/scripts/tests/bootstrap-rollout.test.py exercises the rollout
offline with a fake gh and a recording SLEEP_CMD.
Trigger: push to main (when template files change) or workflow_dispatch
Propagates the Pull Request and Issue templates from this repository (Cratis/Workflows) directly to the default branch of every other non-archived Cratis repository. Silently skips repositories where files are already up to date.
Excluded repositories: Workflows, cratis.github.io, StudioIssues, Dockerfiles, .github, cratis.studio, and the repositories that keep a deliberately customized pull request template (Arc, Arc.Kotlin, Arc.TypeScript, Chronicle, Chronicle.Dapr, Chronicle.Kotlin, Chronicle.Python, Chronicle.Wolverine, Prompter, Scene, Screenplay, Screenplay.CritterStack, Screenplay.Generation, Strategy, Studio, Synopsis). The push replaces a template that differs, so a repository whose template has content beyond a version of the org template is added to REPOS_TO_IGNORE, with the reason in the comment beside it. Excluding a repository skips it entirely, so these repositories also stop receiving issue-template updates. .github is the organization default template (Cratis/.github), shown in repositories without their own; it is kept in step by a pull request in that repository. AI.Distribution is excluded because it is no longer in use.
Secrets required: PAT_WORKFLOWS — classic PAT with repo scope, or fine-grained PAT with Contents read/write + Metadata read. The PAT owner must be a bypass actor on each target repository's branch protection ruleset.
These workflows power CI/CD across the Cratis open-source ecosystem — Chronicle (event sourcing database and runtime), Arc (CQRS for ASP.NET Core), Components (React), the CLI, and more. Documentation lives at cratis.io.