Replies to issues #3, #4 and #5
799 files changed+59888−0showing the first 300 files
Files (300)
- .agents/plugins/marketplace.json+20−0
- .claude-plugin/marketplace.json+55−0
- .github/ISSUE_TEMPLATE/actions_compatibility.md+24−0
- .github/ISSUE_TEMPLATE/bug_report.md+28−0
- .github/ISSUE_TEMPLATE/config.yml+11−0
- .github/ISSUE_TEMPLATE/feature_request.md+18−0
- .github/pull_request_template.md+13−0
- .github/workflows/awesome-lists.yml+37−0
- .github/workflows/ci.yml+61−0
- .github/workflows/d1-replication.yml+111−0
- .github/workflows/deploy.yml+187−0
- .github/workflows/e2e.yml+136−0
- .github/workflows/github-community.yml+29−0
- .github/workflows/live-test.yml+814−0
- .github/workflows/mirror-to-github.yml+51−0
- .gitignore+9−0
- AGENTS.md+78−0
- CHANGELOG.md+55−0
- CITATION.cff+38−0
- CLAUDE.md+1−0
- CODE_OF_CONDUCT.md+83−0
- CONTRIBUTING.md+107−0
- LICENSE+202−0
- NOTICE+14−0
- README.md+5−0
- ROADMAP.md+41−0
- SECURITY.md+35−0
- SUPPORT.md+18−0
- apps/cli/install/agent-setup.sh+84−0
- apps/cli/install/install.ps1+108−0
- apps/cli/install/install.sh+182−0
- apps/cli/package.json+18−0
- apps/cli/src/api.ts+96−0
- apps/cli/src/bin.ts+22−0
- apps/cli/src/cli.ts+261−0
- apps/cli/src/commands/admin.ts+315−0
- apps/cli/src/commands/agent-setup.ts+289−0
- apps/cli/src/commands/auth.ts+358−0
- apps/cli/src/commands/claude-plugins.ts+145−0
- apps/cli/src/commands/codex.ts+71−0
- apps/cli/src/commands/issue.ts+131−0
- apps/cli/src/commands/migrate.ts+522−0
- apps/cli/src/commands/pr.ts+469−0
- apps/cli/src/commands/repo.ts+113−0
- apps/cli/src/commands/run.ts+218−0
- apps/cli/src/commands/runner.ts+263−0
- apps/cli/src/commands/upgrade.ts+43−0
- apps/cli/src/commands/workflow.ts+140−0
- apps/cli/src/config.ts+107−0
- apps/cli/src/git.ts+103−0
- apps/cli/src/github.ts+127−0
- apps/cli/src/io.ts+104−0
- apps/cli/src/login.ts+179−0
- apps/cli/src/main.ts+31−0
- apps/cli/src/output.ts+67−0
- apps/cli/src/version.ts+1−0
- apps/cli/test/config.test.ts+132−0
- apps/cli/test/credential.test.ts+76−0
- apps/cli/test/helpers.ts+213−0
- apps/cli/test/login.test.ts+217−0
- apps/cli/test/migrate.test.ts+188−0
- apps/cli/test/misc.test.ts+186−0
- apps/cli/test/pr.test.ts+307−0
- apps/cli/test/runner.test.ts+159−0
- apps/cli/test/setup.test.ts+412−0
- apps/cli/test/workflow.test.ts+98−0
- apps/cli/tsconfig.json+1−0
- apps/cli/vitest.config.ts+4−0
- apps/diffinery/.gitignore+2−0
- apps/diffinery/claude-plugins/README.md+15−0
- apps/diffinery/claude-plugins/diffinery-0.1.4.zip+0−0
- apps/diffinery/claude-plugins/diffinery-ci-0.2.4.zip+0−0
- apps/diffinery/docs/accounts/account-data.md+72−0
- apps/diffinery/docs/accounts/blocking-and-reporting.md+87−0
- apps/diffinery/docs/accounts/cli-sign-in.md+152−0
- apps/diffinery/docs/accounts/early-access.md+135−0
- apps/diffinery/docs/accounts/invitations.md+340−0
- apps/diffinery/docs/accounts/notifications.md+389−0
- apps/diffinery/docs/accounts/organizations.md+427−0
- apps/diffinery/docs/accounts/passkeys-and-sign-in.md+206−0
- apps/diffinery/docs/accounts/permissions.md+250−0
- apps/diffinery/docs/accounts/profile-and-preferences.md+303−0
- apps/diffinery/docs/accounts/tokens.md+284−0
- apps/diffinery/docs/actions/caching-and-artifacts.md+555−0
- apps/diffinery/docs/actions/compatibility.md+418−0
- apps/diffinery/docs/actions/deploy-to-cloudflare-workers.md+284−0
- apps/diffinery/docs/actions/docker.md+392−0
- apps/diffinery/docs/actions/environments.md+912−0
- apps/diffinery/docs/actions/events.md+684−0
- apps/diffinery/docs/actions/expressions.md+424−0
- apps/diffinery/docs/actions/oidc.md+369−0
- apps/diffinery/docs/actions/overview.md+467−0
- apps/diffinery/docs/actions/reusable-workflows.md+424−0
- apps/diffinery/docs/actions/runners.md+447−0
- apps/diffinery/docs/actions/secrets-and-variables.md+477−0
- apps/diffinery/docs/actions/workflow-permissions.md+350−0
- apps/diffinery/docs/actions/workflow-syntax.md+812−0
- apps/diffinery/docs/admin/site-administration.md+516−0
- apps/diffinery/docs/agents/docs-for-agents.md+97−0
- apps/diffinery/docs/agents/github-cli.md+73−0
- apps/diffinery/docs/agents/mcp.md+537−0
- apps/diffinery/docs/agents/organizations.md+144−0
- apps/diffinery/docs/agents/overview.md+318−0
- apps/diffinery/docs/agents/projects.md+142−0
- apps/diffinery/docs/agents/review.md+161−0
- apps/diffinery/docs/agents/sandboxes.md+222−0
- apps/diffinery/docs/agents/tasks.md+276−0
- apps/diffinery/docs/agents/terminal.md+149−0
- apps/diffinery/docs/agents/today.md+176−0
- apps/diffinery/docs/collaboration/checks-and-statuses.md+393−0
- apps/diffinery/docs/collaboration/issues.md+624−0
- apps/diffinery/docs/collaboration/merge-on-green.md+125−0
- apps/diffinery/docs/collaboration/merge-queue.md+321−0
- apps/diffinery/docs/collaboration/pull-requests.md+962−0
- apps/diffinery/docs/collaboration/releases.md+522−0
- apps/diffinery/docs/concepts.md+220−0
- apps/diffinery/docs/connect.md+57−0
- apps/diffinery/docs/connect/ci-status-in-claude.md+105−0
- apps/diffinery/docs/connect/claude-code.md+139−0
- apps/diffinery/docs/connect/claude.md+62−0
- apps/diffinery/docs/connect/cloud-agents.md+152−0
- apps/diffinery/docs/connect/codex.md+114−0
- apps/diffinery/docs/connect/how-sign-in-works.md+60−0
- apps/diffinery/docs/connect/other-apps.md+69−0
- apps/diffinery/docs/guides.md+16−0
- apps/diffinery/docs/guides/ai-agent-with-guardrails.md+97−0
- apps/diffinery/docs/guides/faster-ci.md+150−0
- apps/diffinery/docs/guides/mirror-from-github.md+44−0
- apps/diffinery/docs/guides/move-github-actions-workflows.md+126−0
- apps/diffinery/docs/images/agent-pull-request.webp+0−0
- apps/diffinery/docs/images/agent-task.webp+0−0
- apps/diffinery/docs/images/connected-apps.webp+0−0
- apps/diffinery/docs/images/consent-claude-code.webp+0−0
- apps/diffinery/docs/images/consent-codex.webp+0−0
- apps/diffinery/docs/images/editor-clone-picker.webp+0−0
- apps/diffinery/docs/images/editor-consent.webp+0−0
- apps/diffinery/docs/images/editor-repositories.webp+0−0
- apps/diffinery/docs/images/jetbrains-consent.webp+0−0
- apps/diffinery/docs/images/jetbrains-get-from-vcs.webp+0−0
- apps/diffinery/docs/images/jetbrains-plugins.webp+0−0
- apps/diffinery/docs/images/jetbrains-repositories.webp+0−0
- apps/diffinery/docs/images/jetbrains-share.webp+0−0
- apps/diffinery/docs/images/merge-on-green.webp+0−0
- apps/diffinery/docs/images/today.webp+0−0
- apps/diffinery/docs/index.md+61−0
- apps/diffinery/docs/migrate-from-github.md+1037−0
- apps/diffinery/docs/nav.json+148−0
- apps/diffinery/docs/packages/container-registry.md+593−0
- apps/diffinery/docs/quickstart.md+281−0
- apps/diffinery/docs/reference/graphql-api.md+97−0
- apps/diffinery/docs/reference/limits.md+398−0
- apps/diffinery/docs/reference/rest-api.md+890−0
- apps/diffinery/docs/reference/webhook-events.md+796−0
- apps/diffinery/docs/repositories/apps.md+119−0
- apps/diffinery/docs/repositories/branch-protection.md+509−0
- apps/diffinery/docs/repositories/browsing-code.md+331−0
- apps/diffinery/docs/repositories/clone-in-jetbrains-ides.md+114−0
- apps/diffinery/docs/repositories/clone-in-your-editor.md+133−0
- apps/diffinery/docs/repositories/git-lfs.md+194−0
- apps/diffinery/docs/repositories/git.md+355−0
- apps/diffinery/docs/repositories/overview.md+574−0
- apps/diffinery/docs/repositories/webhooks.md+352−0
- apps/diffinery/docs/security/audit-log.md+271−0
- apps/diffinery/docs/security/disclosure.md+71−0
- apps/diffinery/docs/security/overview.md+313−0
- apps/diffinery/docs/security/push-protection.md+291−0
- apps/diffinery/docs/self-hosting.md+64−0
- apps/diffinery/docs/self-hosting/architecture.md+328−0
- apps/diffinery/docs/self-hosting/configuration.md+378−0
- apps/diffinery/docs/self-hosting/deploy.md+400−0
- apps/diffinery/docs/self-hosting/status-page.md+137−0
- apps/diffinery/islands/build.mjs+104−0
- apps/diffinery/islands/kumo.tsx+208−0
- apps/diffinery/islands/terminal.ts+101−0
- apps/diffinery/islands/tsconfig.json+10−0
- apps/diffinery/migrations/0001_init.sql+119−0
- apps/diffinery/migrations/0002_passkeys.sql+46−0
- apps/diffinery/migrations/0003_container_registry.sql+84−0
- apps/diffinery/migrations/0004_email.sql+60−0
- apps/diffinery/migrations/0007_repo_topics.sql+2−0
- apps/diffinery/migrations/0008_profile_appearance_avatars.sql+13−0
- apps/diffinery/migrations/0009_org_base_permission.sql+6−0
- apps/diffinery/migrations/0010_imports.sql+56−0
- apps/diffinery/migrations/0011_redirects.sql+34−0
- apps/diffinery/migrations/0012_repo_activity.sql+8−0
- apps/diffinery/migrations/0013_agent_tasks.sql+32−0
- apps/diffinery/migrations/0014_org_secret_repos.sql+9−0
- apps/diffinery/migrations/0015_invite_rate.sql+2−0
- apps/diffinery/migrations/0016_agent_tasks_repo.sql+3−0
- apps/diffinery/migrations/0017_account_invites.sql+4−0
- apps/diffinery/migrations/0018_quotas.sql+23−0
- apps/diffinery/migrations/0019_agent_terminals.sql+26−0
- apps/diffinery/migrations/0020_signup.sql+27−0
- apps/diffinery/migrations/0021_suspensions.sql+20−0
- apps/diffinery/migrations/0022_storage_quota.sql+10−0
- apps/diffinery/migrations/0023_upload_hash_state.sql+3−0
- apps/diffinery/migrations/0024_server_errors.sql+14−0
- apps/diffinery/migrations/0025_moderation.sql+41−0
- apps/diffinery/migrations/0026_account_lifecycle.sql+30−0
- apps/diffinery/migrations/0027_email_suppressions.sql+12−0
- apps/diffinery/migrations/0028_terminal_tasks.sql+3−0
- apps/diffinery/migrations/0029_security_reports.sql+21−0
- apps/diffinery/migrations/0030_agent_usage.sql+26−0
- apps/diffinery/migrations/0031_org_agent_policies.sql+41−0
- apps/diffinery/migrations/0032_mail_digests.sql+9−0
- apps/diffinery/migrations/0033_status_incidents.sql+27−0
- apps/diffinery/migrations/0034_cli_logins.sql+29−0
- apps/diffinery/migrations/0035_scale_indexes.sql+16−0
- apps/diffinery/migrations/0036_oauth.sql+57−0
- apps/diffinery/migrations/0037_early_access.sql+40−0
- apps/diffinery/migrations/0038_agent_setup.sql+26−0
- apps/diffinery/migrations/0039_indexnow.sql+34−0
- apps/diffinery/migrations/0040_indexnow_endpoint.sql+3−0
- apps/diffinery/migrations/0041_account_auto_merge.sql+4−0
- apps/diffinery/migrations/0042_apps.sql+81−0
- apps/diffinery/package.json+54−0
- apps/diffinery/scripts/agent-screenshots.mjs+339−0
- apps/diffinery/scripts/build-docs.mjs+352−0
- apps/diffinery/scripts/docs-screenshots.mjs+145−0
- apps/diffinery/scripts/make-icons.py+120−0
- apps/diffinery/scripts/make-ui-icons.mjs+76−0
- apps/diffinery/site/about.md+47−0
- apps/diffinery/site/agents.md+134−0
- apps/diffinery/site/blog/github-actions-on-cloudflare-containers.md+165−0
- apps/diffinery/site/blog/index.md+14−0
- apps/diffinery/site/built-on-cloudflare.md+144−0
- apps/diffinery/site/compare/bitbucket.md+68−0
- apps/diffinery/site/compare/blacksmith.md+53−0
- apps/diffinery/site/compare/buildkite.md+70−0
- apps/diffinery/site/compare/circleci.md+70−0
- apps/diffinery/site/compare/depot.md+50−0
- apps/diffinery/site/compare/gitea.md+67−0
- apps/diffinery/site/compare/github.md+106−0
- apps/diffinery/site/compare/gitlab.md+58−0
- apps/diffinery/site/compare/index.md+107−0
- apps/diffinery/site/faq.md+143−0
- apps/diffinery/site/features.md+92−0
- apps/diffinery/site/nav.json+6−0
- apps/diffinery/site/open-source.md+82−0
- apps/diffinery/site/performance.md+84−0
- apps/diffinery/site/privacy.md+107−0
- apps/diffinery/site/security.md+51−0
- apps/diffinery/src/accounts/export.ts+277−0
- apps/diffinery/src/accounts/lifecycle.ts+203−0
- apps/diffinery/src/accounts/workflow.ts+59−0
- apps/diffinery/src/agentic/ProjectDO.ts+2146−0
- apps/diffinery/src/agentic/access.ts+45−0
- apps/diffinery/src/agentic/api.ts+342−0
- apps/diffinery/src/agentic/coordinator.ts+218−0
- apps/diffinery/src/agentic/digest.ts+72−0
- apps/diffinery/src/agentic/inflight.ts+26−0
- apps/diffinery/src/agentic/keys.ts+128−0
- apps/diffinery/src/agentic/ledger.ts+139−0
- apps/diffinery/src/agentic/mcp.ts+85−0
- apps/diffinery/src/agentic/org-policy.ts+346−0
- apps/diffinery/src/agentic/org-web.tsx+329−0
- apps/diffinery/src/agentic/project-web.tsx+662−0
- apps/diffinery/src/agentic/project.ts+159−0
- apps/diffinery/src/agentic/projects.ts+251−0
- apps/diffinery/src/agentic/proxy.ts+243−0
- apps/diffinery/src/agentic/review-web.tsx+213−0
- apps/diffinery/src/agentic/review.ts+380−0
- apps/diffinery/src/agentic/reviews.ts+257−0
- apps/diffinery/src/agentic/runtimes.ts+333−0
- apps/diffinery/src/agentic/tasks-web.tsx+835−0
- apps/diffinery/src/agentic/tasks.ts+392−0
- apps/diffinery/src/agentic/terminal-web.tsx+376−0
- apps/diffinery/src/agentic/terminals.ts+298−0
- apps/diffinery/src/agentic/today-repo.ts+296−0
- apps/diffinery/src/agentic/today.ts+313−0
- apps/diffinery/src/agentic/usage.ts+456−0
- apps/diffinery/src/agentic/web.tsx+280−0
- apps/diffinery/src/app.ts+312−0
- apps/diffinery/src/apps/keys.ts+192−0
- apps/diffinery/src/apps/model.ts+152−0
- apps/diffinery/src/apps/service.ts+278−0
- apps/diffinery/src/apps/store.ts+674−0
- apps/diffinery/src/auth/cli-login.ts+237−0
- apps/diffinery/src/auth/dev.ts+40−0
- apps/diffinery/src/auth/invite-proof.ts+30−0
- apps/diffinery/src/auth/oauth.ts+599−0
- apps/diffinery/src/auth/principal.ts+311−0
- apps/diffinery/src/auth/sessions.ts+234−0
- apps/diffinery/src/auth/signup.ts+179−0
- apps/diffinery/src/compose.ts+18−0
- apps/diffinery/src/docs/api-meta.ts+2189−0
- apps/diffinery/src/docs/claude-plugins.ts+32−0
- apps/diffinery/src/docs/pages.ts+272−0
- apps/diffinery/src/docs/reference.ts+448−0
- apps/diffinery/src/docs/web.tsx+430−0
- apps/diffinery/src/env.ts+144−0
- apps/diffinery/src/git/fsck.ts+122−0
- apps/diffinery/src/git/protocol.ts+337−0
- apps/diffinery/src/git/push-contents.ts+230−0
- apps/diffinery/src/git/secret-scan.ts+124−0
- apps/diffinery/src/http/actions-runtime.ts+638−0
- apps/diffinery/src/http/api.ts+1597−0
- apps/diffinery/src/http/apps.ts+382−0
- apps/diffinery/src/http/avatars.ts+230−0
- apps/diffinery/src/http/camo.ts+48−0
added.agents/plugins/marketplace.json+20−0View file
| @@ -1,0 +1,20 @@ | ||
| 1 | +{ | |
| 2 | + "name": "diffinery", | |
| 3 | + "interface": { | |
| 4 | + "displayName": "Diffinery" | |
| 5 | + }, | |
| 6 | + "plugins": [ | |
| 7 | + { | |
| 8 | + "name": "diffinery", | |
| 9 | + "source": { | |
| 10 | + "source": "local", | |
| 11 | + "path": "./plugins/diffinery" | |
| 12 | + }, | |
| 13 | + "policy": { | |
| 14 | + "installation": "AVAILABLE", | |
| 15 | + "authentication": "ON_USE" | |
| 16 | + }, | |
| 17 | + "category": "Coding" | |
| 18 | + } | |
| 19 | + ] | |
| 20 | +} |
added.claude-plugin/marketplace.json+55−0View file
| @@ -1,0 +1,55 @@ | ||
| 1 | +{ | |
| 2 | + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", | |
| 3 | + "name": "diffinery", | |
| 4 | + "owner": { | |
| 5 | + "name": "Codician Team", | |
| 6 | + "url": "https://diffinery.com" | |
| 7 | + }, | |
| 8 | + "metadata": { | |
| 9 | + "description": "Plugins that make coding agents work with Diffinery, the git forge built for AI agents.", | |
| 10 | + "version": "0.2.4" | |
| 11 | + }, | |
| 12 | + "plugins": [ | |
| 13 | + { | |
| 14 | + "name": "diffinery", | |
| 15 | + "source": "./plugins/diffinery", | |
| 16 | + "description": "Work with repositories hosted on Diffinery: pull requests, CI checks, workflow logs and issues through the diffinery CLI and the Diffinery MCP server.", | |
| 17 | + "version": "0.1.4", | |
| 18 | + "author": { | |
| 19 | + "name": "Codician Team" | |
| 20 | + }, | |
| 21 | + "homepage": "https://diffinery.com/docs/connect/claude-code", | |
| 22 | + "repository": "https://diffinery.com/codician/diffinery", | |
| 23 | + "license": "Apache-2.0", | |
| 24 | + "keywords": [ | |
| 25 | + "diffinery", | |
| 26 | + "git", | |
| 27 | + "pull-requests", | |
| 28 | + "ci", | |
| 29 | + "code-review", | |
| 30 | + "mcp" | |
| 31 | + ], | |
| 32 | + "category": "development" | |
| 33 | + }, | |
| 34 | + { | |
| 35 | + "name": "diffinery-ci", | |
| 36 | + "source": "./plugins/diffinery-ci", | |
| 37 | + "description": "Live CI for Diffinery pull requests: the branch's PR and its checks above the prompt and in the status line, a checks pane, and a note to Claude when CI on a commit it pushed settles (also in cloud sessions).", | |
| 38 | + "version": "0.2.4", | |
| 39 | + "author": { | |
| 40 | + "name": "Codician Team" | |
| 41 | + }, | |
| 42 | + "homepage": "https://diffinery.com/docs/connect/ci-status-in-claude", | |
| 43 | + "repository": "https://diffinery.com/codician/diffinery", | |
| 44 | + "license": "Apache-2.0", | |
| 45 | + "keywords": [ | |
| 46 | + "diffinery", | |
| 47 | + "ci", | |
| 48 | + "pull-requests", | |
| 49 | + "status", | |
| 50 | + "mod" | |
| 51 | + ], | |
| 52 | + "category": "development" | |
| 53 | + } | |
| 54 | + ] | |
| 55 | +} |
added.github/ISSUE_TEMPLATE/actions_compatibility.md+24−0View file
| @@ -1,0 +1,24 @@ | ||
| 1 | +--- | |
| 2 | +name: GitHub Actions compatibility | |
| 3 | +about: A workflow or action that works on GitHub behaves differently on Diffinery | |
| 4 | +title: "" | |
| 5 | +labels: actions-compatibility | |
| 6 | +--- | |
| 7 | + | |
| 8 | +<!-- First check https://diffinery.com/docs/actions/compatibility: known differences are listed there. --> | |
| 9 | + | |
| 10 | +**The workflow** | |
| 11 | + | |
| 12 | +```yaml | |
| 13 | +# The smallest workflow that shows the difference. Remove secrets. | |
| 14 | +``` | |
| 15 | + | |
| 16 | +**What GitHub Actions does** | |
| 17 | + | |
| 18 | +**What Diffinery does** | |
| 19 | + | |
| 20 | +<!-- Paste the job log or the error. --> | |
| 21 | + | |
| 22 | +**Runner** | |
| 23 | + | |
| 24 | +<!-- Hosted (which size label, if any) or self-hosted (OS, Docker or not). --> |
added.github/ISSUE_TEMPLATE/bug_report.md+28−0View file
| @@ -1,0 +1,28 @@ | ||
| 1 | +--- | |
| 2 | +name: Bug report | |
| 3 | +about: Something in Diffinery doesn't work as documented | |
| 4 | +title: "" | |
| 5 | +labels: bug | |
| 6 | +--- | |
| 7 | + | |
| 8 | +<!-- Security problem? Please don't file it here: report it privately at https://diffinery.com/security/report --> | |
| 9 | + | |
| 10 | +**What happened** | |
| 11 | + | |
| 12 | +**What you expected to happen** | |
| 13 | + | |
| 14 | +**How to reproduce it** | |
| 15 | + | |
| 16 | +1. | |
| 17 | +2. | |
| 18 | +3. | |
| 19 | + | |
| 20 | +**Where** | |
| 21 | + | |
| 22 | +- Instance: <!-- diffinery.com, or your own instance; which commit of Diffinery it runs if you know --> | |
| 23 | +- Area: <!-- web UI, git, REST/GraphQL API, MCP, Actions/runners, CLI, editor plugin, self-hosting scripts --> | |
| 24 | +- Browser, OS, git or CLI version, if relevant: | |
| 25 | + | |
| 26 | +**Logs or output** | |
| 27 | + | |
| 28 | +<!-- Job logs, CLI output, or the response body. Remove tokens and other secrets first. --> |
added.github/ISSUE_TEMPLATE/config.yml+11−0View file
| @@ -1,0 +1,11 @@ | ||
| 1 | +blank_issues_enabled: true | |
| 2 | +contact_links: | |
| 3 | + - name: Report a security vulnerability | |
| 4 | + url: https://diffinery.com/security/report | |
| 5 | + about: Please report security problems privately, not in a public issue. | |
| 6 | + - name: Documentation | |
| 7 | + url: https://diffinery.com/docs | |
| 8 | + about: Guides for every feature, the REST API, CLI and MCP references, and self-hosting. | |
| 9 | + - name: Questions and help | |
| 10 | + url: https://diffinery.com/faq | |
| 11 | + about: Answers to common questions, with links to the guides. See SUPPORT.md for where to ask the rest. |
added.github/ISSUE_TEMPLATE/feature_request.md+18−0View file
| @@ -1,0 +1,18 @@ | ||
| 1 | +--- | |
| 2 | +name: Feature request | |
| 3 | +about: Suggest something Diffinery should do | |
| 4 | +title: "" | |
| 5 | +labels: enhancement | |
| 6 | +--- | |
| 7 | + | |
| 8 | +**The problem** | |
| 9 | + | |
| 10 | +<!-- What are you trying to do, and what gets in the way today? --> | |
| 11 | + | |
| 12 | +**What you'd like** | |
| 13 | + | |
| 14 | +**How GitHub or other forges handle it, if relevant** | |
| 15 | + | |
| 16 | +<!-- Diffinery aims to be compatible with GitHub where it can. Links to their docs help. --> | |
| 17 | + | |
| 18 | +**Anything else** |
added.github/pull_request_template.md+13−0View file
| @@ -1,0 +1,13 @@ | ||
| 1 | +## What this changes | |
| 2 | + | |
| 3 | +<!-- What the change does and why, in a few sentences. Link the issue it fixes, if any. --> | |
| 4 | + | |
| 5 | +## How it was tested | |
| 6 | + | |
| 7 | +<!-- Unit tests, end-to-end tests, a manual check against `pnpm dev`, or a deploy to your own instance. --> | |
| 8 | + | |
| 9 | +## Checklist | |
| 10 | + | |
| 11 | +- [ ] `pnpm -r typecheck` and `pnpm -r test` pass | |
| 12 | +- [ ] User- or admin-visible changes update the docs in `apps/diffinery/docs` | |
| 13 | +- [ ] New D1 migrations use the next free number in `apps/diffinery/migrations` |
added.github/workflows/awesome-lists.yml+37−0View file
| @@ -1,0 +1,37 @@ | ||
| 1 | +name: Suggest Diffinery to awesome lists | |
| 2 | + | |
| 3 | +# Opens one pull request per curated list in scripts/awesome-lists/lists.mjs, as the account | |
| 4 | +# that owns OUTREACH_TOKEN, so they show Codician rather than a person. Lists where Diffinery is | |
| 5 | +# already listed or suggested are left alone, and lists whose rules don't allow it yet are | |
| 6 | +# skipped until their day, so running it again is safe. Run it by hand from the Actions tab. | |
| 7 | +# | |
| 8 | +# OUTREACH_TOKEN: a classic personal access token of the Codician GitHub account with only the | |
| 9 | +# public_repo scope (fine-grained tokens can't open pull requests on other people's repositories). | |
| 10 | +on: | |
| 11 | + workflow_dispatch: | |
| 12 | + inputs: | |
| 13 | + lists: | |
| 14 | + description: "List ids, comma-separated, or 'due' for every list whose rules allow it today" | |
| 15 | + type: string | |
| 16 | + default: due | |
| 17 | + dry_run: | |
| 18 | + description: Only report what would change | |
| 19 | + type: boolean | |
| 20 | + default: false | |
| 21 | + | |
| 22 | +permissions: | |
| 23 | + contents: read | |
| 24 | + | |
| 25 | +jobs: | |
| 26 | + suggest: | |
| 27 | + name: Open the list pull requests | |
| 28 | + if: ${{ github.server_url != 'https://github.com' && github.repository == 'codician/diffinery' }} | |
| 29 | + runs-on: ubuntu-latest | |
| 30 | + environment: github-mirror | |
| 31 | + timeout-minutes: 10 | |
| 32 | + steps: | |
| 33 | + - uses: actions/checkout@v4 | |
| 34 | + - run: node scripts/awesome-lists.mjs --lists="$LISTS" ${{ inputs.dry_run && '--dry-run' || '' }} | |
| 35 | + env: | |
| 36 | + LISTS: ${{ inputs.lists }} | |
| 37 | + OUTREACH_TOKEN: ${{ secrets.OUTREACH_TOKEN }} |
added.github/workflows/ci.yml+61−0View file
| @@ -1,0 +1,61 @@ | ||
| 1 | +name: CI | |
| 2 | + | |
| 3 | +# Deploy waits for this workflow to pass on main. The end-to-end suite is its own | |
| 4 | +# workflow (e2e.yml), so deploys don't wait for it too. | |
| 5 | + | |
| 6 | +on: | |
| 7 | + push: | |
| 8 | + branches: [main] | |
| 9 | + pull_request: | |
| 10 | + | |
| 11 | +permissions: | |
| 12 | + contents: read | |
| 13 | + | |
| 14 | +concurrency: | |
| 15 | + group: ci-${{ github.ref }} | |
| 16 | + cancel-in-progress: true | |
| 17 | + | |
| 18 | +jobs: | |
| 19 | + # Two jobs in parallel: each needs the install and build, but typecheck and the unit | |
| 20 | + # tests together took longer than either does alone. | |
| 21 | + typecheck: | |
| 22 | + name: Typecheck and bundle | |
| 23 | + runs-on: ubuntu-latest | |
| 24 | + timeout-minutes: 20 | |
| 25 | + steps: | |
| 26 | + - uses: actions/checkout@v4 | |
| 27 | + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 | |
| 28 | + - uses: actions/setup-node@v4 | |
| 29 | + with: | |
| 30 | + node-version: 22 | |
| 31 | + cache: pnpm | |
| 32 | + - run: pnpm install --frozen-lockfile | |
| 33 | + - name: Build | |
| 34 | + run: pnpm -r --if-present build | |
| 35 | + - name: Typecheck | |
| 36 | + run: pnpm -r --if-present typecheck | |
| 37 | + - name: Bundle the Worker (dry run) | |
| 38 | + working-directory: apps/diffinery | |
| 39 | + run: pnpm exec wrangler deploy --dry-run --env dev --outdir dist | |
| 40 | + | |
| 41 | + test: | |
| 42 | + name: Unit tests (${{ matrix.shard }}/2) | |
| 43 | + runs-on: ubuntu-latest | |
| 44 | + timeout-minutes: 20 | |
| 45 | + # Two halves of every package's test files, side by side: CI is the first step of a deploy. | |
| 46 | + strategy: | |
| 47 | + fail-fast: false | |
| 48 | + matrix: | |
| 49 | + shard: [1, 2] | |
| 50 | + steps: | |
| 51 | + - uses: actions/checkout@v4 | |
| 52 | + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 | |
| 53 | + - uses: actions/setup-node@v4 | |
| 54 | + with: | |
| 55 | + node-version: 22 | |
| 56 | + cache: pnpm | |
| 57 | + - run: pnpm install --frozen-lockfile | |
| 58 | + - name: Build | |
| 59 | + run: pnpm -r --if-present build | |
| 60 | + - name: Unit tests | |
| 61 | + run: pnpm -r --if-present run test --shard=${{ matrix.shard }}/2 |
added.github/workflows/d1-replication.yml+111−0View file
| @@ -1,0 +1,111 @@ | ||
| 1 | +name: D1 read replication | |
| 2 | + | |
| 3 | +# Checks the D1 Sessions API with read replication on a throwaway database, then | |
| 4 | +# (when asked) turns read replication on for the production database. | |
| 5 | +# Uses the repository secrets CLOUDFLARE_API_TOKEN (D1 and Workers Scripts: Edit) | |
| 6 | +# and CLOUDFLARE_ACCOUNT_ID. | |
| 7 | +on: | |
| 8 | + workflow_dispatch: | |
| 9 | + inputs: | |
| 10 | + enable_production: | |
| 11 | + description: Turn on read replication for the production database after the check passes | |
| 12 | + type: boolean | |
| 13 | + default: false | |
| 14 | + | |
| 15 | +permissions: | |
| 16 | + contents: read | |
| 17 | + | |
| 18 | +jobs: | |
| 19 | + check: | |
| 20 | + name: Check sessions on a test database | |
| 21 | + runs-on: ubuntu-latest | |
| 22 | + # The Cloudflare secrets are the production environment's. | |
| 23 | + environment: production | |
| 24 | + timeout-minutes: 20 | |
| 25 | + env: | |
| 26 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 27 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 28 | + NAME: diffinery-d1-probe-${{ github.run_id }} | |
| 29 | + steps: | |
| 30 | + - uses: actions/checkout@v4 | |
| 31 | + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 | |
| 32 | + - uses: actions/setup-node@v4 | |
| 33 | + with: | |
| 34 | + node-version: 22 | |
| 35 | + cache: pnpm | |
| 36 | + - run: pnpm install --frozen-lockfile | |
| 37 | + - name: Create a test database with read replication | |
| 38 | + run: | | |
| 39 | + set -euo pipefail | |
| 40 | + API="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID" | |
| 41 | + auth=(-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json") | |
| 42 | + id=$(curl -sS -X POST "$API/d1/database" "${auth[@]}" --data "{\"name\":\"$NAME\"}" | jq -r '.result.uuid // empty') | |
| 43 | + [ -n "$id" ] || { echo "::error::could not create the test database"; exit 1; } | |
| 44 | + echo "D1_ID=$id" >> "$GITHUB_ENV" | |
| 45 | + curl -sS -X PUT "$API/d1/database/$id" "${auth[@]}" --data '{"read_replication":{"mode":"auto"}}' | jq -e '.success' >/dev/null | |
| 46 | + mode=$(curl -sS "$API/d1/database/$id" "${auth[@]}" | jq -r '.result.read_replication.mode') | |
| 47 | + echo "read replication on the test database: $mode" | |
| 48 | + [ "$mode" = auto ] || { echo "::error::read replication did not turn on"; exit 1; } | |
| 49 | + - name: Deploy the probe Worker | |
| 50 | + working-directory: scripts/d1-probe | |
| 51 | + run: | | |
| 52 | + set -euo pipefail | |
| 53 | + cat > wrangler.json <<JSON | |
| 54 | + {"name":"$NAME","main":"worker.js","compatibility_date":"2026-10-01","workers_dev":true, | |
| 55 | + "d1_databases":[{"binding":"DB","database_name":"$NAME","database_id":"$D1_ID"}]} | |
| 56 | + JSON | |
| 57 | + W=(pnpm --dir "$GITHUB_WORKSPACE/apps/diffinery" exec wrangler) | |
| 58 | + "${W[@]}" d1 execute "$NAME" --remote -c "$PWD/wrangler.json" --command "CREATE TABLE probe (k TEXT PRIMARY KEY, v TEXT NOT NULL)" | |
| 59 | + "${W[@]}" deploy -c "$PWD/wrangler.json" | |
| 60 | + sub=$(curl -sS "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/subdomain" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -r '.result.subdomain // empty') | |
| 61 | + [ -n "$sub" ] || { echo "::error::the account has no workers.dev subdomain"; exit 1; } | |
| 62 | + echo "PROBE_URL=https://$NAME.$sub.workers.dev" >> "$GITHUB_ENV" | |
| 63 | + - name: Read your own writes through bookmarks | |
| 64 | + run: | | |
| 65 | + set -uo pipefail | |
| 66 | + for i in $(seq 1 30); do | |
| 67 | + body=$(curl -sS "$PROBE_URL/check?rounds=25") && jq -e . >/dev/null <<<"$body" && break | |
| 68 | + sleep 5 | |
| 69 | + done | |
| 70 | + echo "$body" | jq . | |
| 71 | + jq -e '.ok' >/dev/null <<<"$body" || { echo "::error::a read after a bookmark missed the write, or a query failed"; exit 1; } | |
| 72 | + echo "every read from a bookmark saw its write ($(jq -r '.replicaReads' <<<"$body") served by replicas)" | |
| 73 | + - name: Remove the probe and the test database | |
| 74 | + if: always() | |
| 75 | + run: | | |
| 76 | + cd scripts/d1-probe | |
| 77 | + [ -f wrangler.json ] && pnpm --dir "$GITHUB_WORKSPACE/apps/diffinery" exec wrangler delete -c "$PWD/wrangler.json" --force || true | |
| 78 | + [ -n "${D1_ID:-}" ] && curl -sS -X DELETE "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/d1/database/$D1_ID" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -c '{success}' || true | |
| 79 | + | |
| 80 | + enable: | |
| 81 | + name: Turn on read replication in production | |
| 82 | + needs: check | |
| 83 | + if: ${{ inputs.enable_production }} | |
| 84 | + runs-on: ubuntu-latest | |
| 85 | + environment: | |
| 86 | + name: production | |
| 87 | + url: https://${{ vars.DIFFINERY_DOMAIN || 'diffinery.com' }} | |
| 88 | + env: | |
| 89 | + DIFFINERY_DOMAIN: ${{ vars.DIFFINERY_DOMAIN || 'diffinery.com' }} | |
| 90 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 91 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 92 | + steps: | |
| 93 | + - name: Enable and verify | |
| 94 | + run: | | |
| 95 | + set -euo pipefail | |
| 96 | + API="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID" | |
| 97 | + auth=(-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json") | |
| 98 | + id=$(curl -sS "$API/d1/database?name=diffinery&per_page=100" "${auth[@]}" | jq -r '.result[]? | select(.name=="diffinery") | .uuid' | head -n1) | |
| 99 | + [ -n "$id" ] || { echo "::error::production database not found"; exit 1; } | |
| 100 | + curl -sS -X PUT "$API/d1/database/$id" "${auth[@]}" --data '{"read_replication":{"mode":"auto"}}' | jq -e '.success' >/dev/null | |
| 101 | + mode=$(curl -sS "$API/d1/database/$id" "${auth[@]}" | jq -r '.result.read_replication.mode') | |
| 102 | + echo "read replication on diffinery: $mode" | |
| 103 | + [ "$mode" = auto ] || { echo "::error::read replication did not turn on"; exit 1; } | |
| 104 | + - name: Check the site still reads and writes | |
| 105 | + run: | | |
| 106 | + set -euo pipefail | |
| 107 | + for p in /healthz /login /sitemap.xml; do | |
| 108 | + code=$(curl -sS -o /dev/null -w '%{http_code}' "https://$DIFFINERY_DOMAIN$p"); echo "$p -> $code"; [ "$code" = 200 ] | |
| 109 | + done | |
| 110 | + bm=$(curl -sS -D - -o /dev/null https://$DIFFINERY_DOMAIN/sitemap.xml | tr -d '\r' | sed -n 's/^x-d1-bookmark: //Ip') | |
| 111 | + echo "x-d1-bookmark: $bm"; [ -n "$bm" ] |
added.github/workflows/deploy.yml+187−0View file
| @@ -1,0 +1,187 @@ | ||
| 1 | +name: Deploy | |
| 2 | + | |
| 3 | +# Deploys Diffinery from main. Uses the repository secrets | |
| 4 | +# CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID, and these optional repository | |
| 5 | +# variables (the same ones scripts/bootstrap.sh reads): | |
| 6 | +# DIFFINERY_DOMAIN where it is served (default diffinery.com) | |
| 7 | +# DIFFINERY_ZONE the Cloudflare zone for the route (default: the domain's last two labels) | |
| 8 | +# MAIL_DOMAIN sends from notifications@<MAIL_DOMAIN> (default DIFFINERY_DOMAIN) | |
| 9 | +# ACCESS_TEAM_DOMAIN e.g. example.cloudflareaccess.com | |
| 10 | +# | |
| 11 | +# It deploys a main commit only once CI (typecheck, unit tests and the Worker bundle) has | |
| 12 | +# passed on it, so it doesn't run those itself. End-to-end tests run alongside the deploy. | |
| 13 | +# Only main's current tip deploys: re-running an old CI run (which anyone with write access | |
| 14 | +# can do) doesn't bring back a version from before a fix. | |
| 15 | +# | |
| 16 | +# The Cloudflare secrets are the production environment's, and only the steps that talk to | |
| 17 | +# Cloudflare get them: installs, builds and third-party actions run without them. | |
| 18 | +on: | |
| 19 | + workflow_run: | |
| 20 | + workflows: [CI] | |
| 21 | + types: [completed] | |
| 22 | + branches: [main] | |
| 23 | + workflow_dispatch: | |
| 24 | + | |
| 25 | +permissions: | |
| 26 | + contents: read | |
| 27 | + | |
| 28 | +concurrency: | |
| 29 | + group: deploy-production | |
| 30 | + cancel-in-progress: false | |
| 31 | + | |
| 32 | +jobs: | |
| 33 | + gate: | |
| 34 | + name: Main's current tip | |
| 35 | + # Production deploys from Diffinery itself, on its own hosted runners (any | |
| 36 | + # Diffinery, so self-hosted copies deploy themselves too). Running this | |
| 37 | + # workflow by hand on GitHub still deploys, as a fallback. | |
| 38 | + if: ${{ github.event_name == 'workflow_dispatch' || (github.server_url != 'https://github.com' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push') }} | |
| 39 | + runs-on: ubuntu-latest | |
| 40 | + timeout-minutes: 5 | |
| 41 | + outputs: | |
| 42 | + deploy: ${{ steps.tip.outputs.deploy }} | |
| 43 | + steps: | |
| 44 | + - name: Deploy only main's current tip | |
| 45 | + id: tip | |
| 46 | + env: | |
| 47 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| 48 | + WANT: ${{ github.event.workflow_run.head_sha || github.sha }} | |
| 49 | + REF: ${{ github.ref }} | |
| 50 | + run: | | |
| 51 | + set -euo pipefail | |
| 52 | + if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ] && [ "$REF" != refs/heads/main ]; then | |
| 53 | + echo "::error::Deploy runs from main only (this run is on $REF)"; exit 1 | |
| 54 | + fi | |
| 55 | + tip=$(curl -fsS "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/branches/main" -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" | jq -r .commit.sha) | |
| 56 | + if [ "$tip" = "$WANT" ]; then | |
| 57 | + echo "deploy=true" >> "$GITHUB_OUTPUT" | |
| 58 | + echo "deploying $WANT, main's tip" | |
| 59 | + else | |
| 60 | + # A later commit's CI run deploys it: a re-run of an old CI run deploys nothing. | |
| 61 | + echo "deploy=false" >> "$GITHUB_OUTPUT" | |
| 62 | + echo "::notice::not deploying $WANT: main is at $tip now, and that commit's own run deploys it" | |
| 63 | + fi | |
| 64 | + | |
| 65 | + deploy: | |
| 66 | + name: Deploy to Cloudflare | |
| 67 | + needs: gate | |
| 68 | + if: ${{ needs.gate.outputs.deploy == 'true' }} | |
| 69 | + runs-on: ubuntu-latest | |
| 70 | + timeout-minutes: 45 | |
| 71 | + environment: | |
| 72 | + name: production | |
| 73 | + url: https://${{ vars.DIFFINERY_DOMAIN || 'diffinery.com' }} | |
| 74 | + env: | |
| 75 | + DIFFINERY_DOMAIN: ${{ vars.DIFFINERY_DOMAIN || 'diffinery.com' }} | |
| 76 | + DIFFINERY_ZONE: ${{ vars.DIFFINERY_ZONE }} | |
| 77 | + MAIL_DOMAIN: ${{ vars.MAIL_DOMAIN }} | |
| 78 | + ACCESS_TEAM_DOMAIN: ${{ vars.ACCESS_TEAM_DOMAIN }} | |
| 79 | + # Who becomes site administrator on first sign-in. Unset keeps the deployed Worker's list (scripts/bootstrap-admins.sh). | |
| 80 | + BOOTSTRAP_ADMINS: ${{ vars.BOOTSTRAP_ADMINS }} | |
| 81 | + steps: | |
| 82 | + - uses: actions/checkout@v4 | |
| 83 | + with: | |
| 84 | + # The commit CI passed on, not whatever main is by now. | |
| 85 | + ref: ${{ github.event.workflow_run.head_sha || github.sha }} | |
| 86 | + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 | |
| 87 | + - uses: actions/setup-node@v4 | |
| 88 | + with: | |
| 89 | + node-version: 22 | |
| 90 | + cache: pnpm | |
| 91 | + - run: pnpm install --frozen-lockfile | |
| 92 | + - name: Build runner agent and web islands | |
| 93 | + run: pnpm -r --if-present build | |
| 94 | + - name: Runner image (built only when the runner changed) | |
| 95 | + env: | |
| 96 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 97 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 98 | + run: scripts/runner-image.sh | |
| 99 | + | |
| 100 | + - name: Create or update Cloudflare resources | |
| 101 | + id: bootstrap | |
| 102 | + env: | |
| 103 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 104 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 105 | + run: scripts/bootstrap.sh | |
| 106 | + | |
| 107 | + - name: Deploy Worker and runner image | |
| 108 | + working-directory: apps/diffinery | |
| 109 | + env: | |
| 110 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 111 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 112 | + run: pnpm exec wrangler deploy -c wrangler.deploy.jsonc | |
| 113 | + | |
| 114 | + - name: Wait for new containers to get the new image | |
| 115 | + env: | |
| 116 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 117 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 118 | + # The Live test follows this workflow, and its checks must run on the new image. | |
| 119 | + # This job runs on an old-image container, so finishing after the rollout's last step | |
| 120 | + # also shows that a deploy doesn't stop a running job. | |
| 121 | + run: scripts/wait-container-rollout.sh | |
| 122 | + | |
| 123 | + - name: Worker secrets (generated on the first deploy; TURNSTILE_SECRET when missing) | |
| 124 | + env: | |
| 125 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 126 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 127 | + run: scripts/ensure-secrets.sh | |
| 128 | + | |
| 129 | + - name: Finish Access setup on first deploy | |
| 130 | + if: steps.bootstrap.outputs.access_team == '' | |
| 131 | + env: | |
| 132 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 133 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 134 | + run: | | |
| 135 | + scripts/bootstrap.sh | |
| 136 | + cd apps/diffinery && pnpm exec wrangler deploy -c wrangler.deploy.jsonc --containers-rollout none | |
| 137 | + | |
| 138 | + - name: Smoke test | |
| 139 | + run: | | |
| 140 | + # A new custom domain can take a few minutes to get its certificate. | |
| 141 | + for i in $(seq 1 30); do | |
| 142 | + code=$(curl -s -o /dev/null -w '%{http_code}' https://$DIFFINERY_DOMAIN/healthz) && [ "$code" = 200 ] && exit 0 | |
| 143 | + sleep 10 | |
| 144 | + done | |
| 145 | + echo "::error::https://$DIFFINERY_DOMAIN/healthz did not return 200 (last: $code). Check that the $DIFFINERY_DOMAIN DNS record is proxied (orange cloud)."; exit 1 | |
| 146 | + | |
| 147 | + - name: Check sign-in pages | |
| 148 | + run: | | |
| 149 | + set -u | |
| 150 | + page=$(curl -sS https://$DIFFINERY_DOMAIN/login) | |
| 151 | + grep -q "Sign in with a passkey" <<<"$page" || { echo "::error::/login is not the Diffinery sign-in page"; exit 1; } | |
| 152 | + echo "/login serves the passkey sign-in page" | |
| 153 | + loc=$(curl -sS -o /dev/null -w '%{http_code} %{redirect_url}' https://$DIFFINERY_DOMAIN/auth/access) | |
| 154 | + echo "/auth/access -> $loc" | |
| 155 | + [[ "$loc" == 302\ https://*.cloudflareaccess.com/* ]] || echo "::warning::/auth/access is not behind Cloudflare Access yet, so email recovery and first admin sign-in will not work" | |
| 156 | + report=$(curl -sS -w '\n%{http_code}' https://$DIFFINERY_DOMAIN/security/report) | |
| 157 | + code=${report##*$'\n'} | |
| 158 | + if grep -q 'class="cf-turnstile"' <<<"$report"; then | |
| 159 | + echo "/security/report shows Turnstile to people who aren't signed in" | |
| 160 | + elif grep -q 'has no Turnstile configured' <<<"$report"; then | |
| 161 | + echo "::warning::/security/report has no Turnstile, so only people who are signed in can send security reports: the Worker lacks TURNSTILE_SITE_KEY or TURNSTILE_SECRET (see the bootstrap and Worker secrets steps)" | |
| 162 | + else | |
| 163 | + echo "::warning::/security/report has no Turnstile, so only people who are signed in can send security reports: it answered $code without the form" | |
| 164 | + fi | |
| 165 | + for kind in js css; do | |
| 166 | + asset=$(grep -o "/-/kumo/[a-z0-9]*\.$kind" <<<"$page" | head -1) | |
| 167 | + [ -n "$asset" ] || { echo "::error::/login does not load the Kumo $kind bundle"; exit 1; } | |
| 168 | + code=$(curl -sS -o /dev/null -w '%{http_code}' "https://$DIFFINERY_DOMAIN$asset") | |
| 169 | + [ "$code" = 200 ] || { echo "::error::$asset returned $code"; exit 1; } | |
| 170 | + echo "$asset -> 200" | |
| 171 | + done | |
| 172 | + - name: Check favicon and search metadata | |
| 173 | + run: | | |
| 174 | + set -u | |
| 175 | + for p in /favicon.ico /favicon.svg /favicon-48.png /apple-touch-icon.png /og-image.png /site.webmanifest /robots.txt /sitemap.xml; do | |
| 176 | + code=$(curl -sS -o /dev/null -w '%{http_code} %{content_type}' "https://$DIFFINERY_DOMAIN$p") | |
| 177 | + [[ "$code" == 200\ * ]] || { echo "::error::$p returned $code"; exit 1; } | |
| 178 | + echo "$p -> $code" | |
| 179 | + done | |
| 180 | + home=$(curl -sS https://$DIFFINERY_DOMAIN/) | |
| 181 | + for tag in 'rel="icon" href="/favicon.ico"' 'property="og:image"' 'rel="canonical"' 'application/ld+json'; do | |
| 182 | + grep -qF "$tag" <<<"$home" || { echo "::error::the home page is missing $tag"; exit 1; } | |
| 183 | + done | |
| 184 | + echo "the home page carries the favicon, canonical, Open Graph and structured data tags" | |
| 185 | + bm=$(curl -sS -D - -o /dev/null https://$DIFFINERY_DOMAIN/sitemap.xml | tr -d '\r' | sed -n 's/^x-d1-bookmark: //Ip') | |
| 186 | + [ -n "$bm" ] || { echo "::error::D1 queries don't run in a session (no x-d1-bookmark header)"; exit 1; } | |
| 187 | + echo "D1 sessions are on (bookmark $bm)" |
added.github/workflows/e2e.yml+136−0View file
| @@ -1,0 +1,136 @@ | ||
| 1 | +name: End-to-end | |
| 2 | + | |
| 3 | +on: | |
| 4 | + push: | |
| 5 | + branches: [main] | |
| 6 | + pull_request: | |
| 7 | + | |
| 8 | +permissions: | |
| 9 | + contents: read | |
| 10 | + | |
| 11 | +concurrency: | |
| 12 | + group: e2e-${{ github.ref }} | |
| 13 | + cancel-in-progress: true | |
| 14 | + | |
| 15 | +jobs: | |
| 16 | + # The suite in parallel shards of about the same length: the git, pull request and | |
| 17 | + # Actions file split in three (its real histories and large pushes on their own), agents, security, and | |
| 18 | + # every other test/e2e/*.test.mjs file (so a new file runs without editing this). | |
| 19 | + # Only the last shard needs a browser (passkeys). | |
| 20 | + e2e: | |
| 21 | + name: End-to-end (${{ matrix.name }}) | |
| 22 | + runs-on: ubuntu-latest | |
| 23 | + timeout-minutes: 20 | |
| 24 | + strategy: | |
| 25 | + fail-fast: false | |
| 26 | + matrix: | |
| 27 | + include: | |
| 28 | + - name: git, PRs, Actions | |
| 29 | + shard: core | |
| 30 | + - name: real histories | |
| 31 | + shard: histories | |
| 32 | + - name: large pushes | |
| 33 | + shard: large | |
| 34 | + - name: agent reviews, tasks, Today | |
| 35 | + shard: agents | |
| 36 | + - name: security | |
| 37 | + shard: security | |
| 38 | + - name: passkeys, registry, email | |
| 39 | + shard: rest | |
| 40 | + steps: | |
| 41 | + - uses: actions/checkout@v4 | |
| 42 | + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 | |
| 43 | + - uses: actions/setup-node@v4 | |
| 44 | + with: | |
| 45 | + node-version: 22 | |
| 46 | + cache: pnpm | |
| 47 | + - run: pnpm install --frozen-lockfile | |
| 48 | + - run: pnpm -r --if-present build | |
| 49 | + - if: matrix.shard == 'rest' | |
| 50 | + run: pnpm --filter @diffinery/app exec playwright install --with-deps chromium | |
| 51 | + # Agent runs (terminal tasks, and headless runs in the agents shard) are in a bubblewrap jail | |
| 52 | + # (containers/runner/src/jail.ts); Ubuntu's AppArmor otherwise keeps | |
| 53 | + # it from creating user namespaces. Diffinery's own runner image already has bwrap and no sudo, running as root. | |
| 54 | + - if: matrix.shard == 'rest' || matrix.shard == 'agents' | |
| 55 | + run: | | |
| 56 | + as_root() { if [ "$(id -u)" = 0 ]; then "$@"; else sudo "$@"; fi; } | |
| 57 | + if ! command -v bwrap >/dev/null; then | |
| 58 | + as_root apt-get install -y --no-install-recommends bubblewrap || { as_root apt-get update -qq && as_root apt-get install -y --no-install-recommends bubblewrap; } | |
| 59 | + fi | |
| 60 | + as_root sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true | |
| 61 | + bwrap --unshare-all --ro-bind / / --dev /dev --proc /proc -- true | |
| 62 | + - name: Run the shard's tests | |
| 63 | + working-directory: apps/diffinery | |
| 64 | + run: | | |
| 65 | + # Shards of similar length; a new test file runs in "rest" unless it's added to another. | |
| 66 | + core='/e2e\.test\.mjs$' | |
| 67 | + agents='/(agentic|review|tasks)\.test\.mjs$' | |
| 68 | + # security.test.mjs, security-*.test.mjs (security-txt's too) and status.test.mjs. | |
| 69 | + security='/(security(-[a-z-]+)?|status)\.test\.mjs$' | |
| 70 | + # The core file's real histories and large pushes take minutes each (jquery's alone 2 to 3 on | |
| 71 | + # Diffinery's runners): two shards of their own, so no part of the file runs into node's | |
| 72 | + # per-file timeout, which is the --test-timeout here. | |
| 73 | + histories='^real histories:' | |
| 74 | + large='^(large files|large pushes):' | |
| 75 | + opts=(--test-timeout=600000) | |
| 76 | + case "${{ matrix.shard }}" in | |
| 77 | + core) files=$(ls test/e2e/*.test.mjs | grep -E "$core"); opts+=(--test-skip-pattern="$histories" --test-skip-pattern="$large") ;; | |
| 78 | + histories) files=$(ls test/e2e/*.test.mjs | grep -E "$core"); opts=(--test-timeout=900000 --test-name-pattern="$histories") ;; | |
| 79 | + large) files=$(ls test/e2e/*.test.mjs | grep -E "$core"); opts+=(--test-name-pattern="$large") ;; | |
| 80 | + agents) files=$(ls test/e2e/*.test.mjs | grep -E "$agents") ;; | |
| 81 | + security) files=$(ls test/e2e/*.test.mjs | grep -E "$security") ;; | |
| 82 | + *) files=$(ls test/e2e/*.test.mjs | grep -vE "$core|$agents|$security") ;; | |
| 83 | + esac | |
| 84 | + echo "$files" | |
| 85 | + # Two files at a time: each starts its own local server on its own port, database and | |
| 86 | + # directory, so they don't share state, and a file's server mostly keeps one core busy. | |
| 87 | + node --test --test-concurrency=2 "${opts[@]}" $files | |
| 88 | + | |
| 89 | + # Docker as the hosted runner image runs it, with this change's docker wrapper, daemon config | |
| 90 | + # and agent (its registry relay): this job runs on a hosted runner, so the real dockerd is there. | |
| 91 | + # A daemon an earlier step started is stopped first, so the first uses below start it afresh. | |
| 92 | + runner-docker: | |
| 93 | + name: End-to-end (runner Docker) | |
| 94 | + runs-on: ubuntu-latest | |
| 95 | + timeout-minutes: 20 | |
| 96 | + steps: | |
| 97 | + - uses: actions/checkout@v4 | |
| 98 | + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 | |
| 99 | + - uses: actions/setup-node@v4 | |
| 100 | + with: | |
| 101 | + node-version: 22 | |
| 102 | + cache: pnpm | |
| 103 | + - run: pnpm install --frozen-lockfile | |
| 104 | + - run: pnpm --filter @diffinery/runner build | |
| 105 | + - name: Use this change's Docker wrapper, daemon config and agent | |
| 106 | + run: | | |
| 107 | + set -euo pipefail | |
| 108 | + as_root() { if [ "$(id -u)" = 0 ]; then "$@"; else sudo "$@"; fi; } | |
| 109 | + if [ ! -f /opt/diffinery/agent.mjs ] || ! grep -q "registry relay" /usr/local/bin/docker 2>/dev/null; then | |
| 110 | + echo "::error::this job needs a Diffinery hosted runner (its docker wrapper and agent)" | |
| 111 | + exit 1 | |
| 112 | + fi | |
| 113 | + as_root pkill -x dockerd || true | |
| 114 | + as_root pkill -f -- "--registry-relay" || true | |
| 115 | + for _ in $(seq 1 60); do pgrep -x dockerd >/dev/null || break; sleep 0.5; done | |
| 116 | + as_root rm -f /run/diffinery-dockerd.ready | |
| 117 | + as_root cp --remove-destination containers/runner/image/docker /usr/local/bin/docker | |
| 118 | + as_root cp --remove-destination containers/runner/image/daemon.json /etc/docker/daemon.json | |
| 119 | + as_root cp --remove-destination containers/runner/dist/agent.mjs /opt/diffinery/agent.mjs | |
| 120 | + - name: Docker's first uses, several at once | |
| 121 | + timeout-minutes: 4 | |
| 122 | + run: | | |
| 123 | + set -uo pipefail | |
| 124 | + start=$(date +%s) | |
| 125 | + docker info >/dev/null & a=$! | |
| 126 | + docker ps >/dev/null & b=$! | |
| 127 | + docker version >/dev/null & c=$! | |
| 128 | + failed=0 | |
| 129 | + for p in $a $b $c; do wait "$p" || failed=1; done | |
| 130 | + echo "three docker commands at once, the daemon starting: $(( $(date +%s) - start )) s" | |
| 131 | + [ "$failed" = 0 ] || { echo "::error::a docker command failed while the daemon started"; exit 1; } | |
| 132 | + docker run --rm mirror.gcr.io/library/alpine:3.20 echo "container ok" | |
| 133 | + - name: Runner Docker tests (job containers, services, Docker actions) | |
| 134 | + timeout-minutes: 10 | |
| 135 | + working-directory: containers/runner | |
| 136 | + run: pnpm exec vitest run test/docker.test.ts test/docker-wrapper.test.ts |
added.github/workflows/github-community.yml+29−0View file
| @@ -1,0 +1,29 @@ | ||
| 1 | +name: GitHub community setup | |
| 2 | + | |
| 3 | +# Sets up the GitHub mirror's community side as the account that owns MIRROR_TOKEN, so it | |
| 4 | +# shows Codician rather than a person: labels, Discussions with the welcome post, and the | |
| 5 | +# pinned roadmap issue (scripts/github-community.mjs). Only creates what is missing, so | |
| 6 | +# running it again changes nothing. Run it by hand from the Actions tab. | |
| 7 | +on: | |
| 8 | + workflow_dispatch: | |
| 9 | + inputs: | |
| 10 | + dry_run: | |
| 11 | + description: Only report what would change | |
| 12 | + type: boolean | |
| 13 | + default: false | |
| 14 | + | |
| 15 | +permissions: | |
| 16 | + contents: read | |
| 17 | + | |
| 18 | +jobs: | |
| 19 | + setup: | |
| 20 | + name: Labels, welcome discussion and roadmap issue | |
| 21 | + if: ${{ github.server_url != 'https://github.com' && github.repository == 'codician/diffinery' }} | |
| 22 | + runs-on: ubuntu-latest | |
| 23 | + environment: github-mirror | |
| 24 | + timeout-minutes: 5 | |
| 25 | + steps: | |
| 26 | + - uses: actions/checkout@v4 | |
| 27 | + - run: node scripts/github-community.mjs ${{ inputs.dry_run && '--dry-run' || '' }} | |
| 28 | + env: | |
| 29 | + MIRROR_TOKEN: ${{ secrets.MIRROR_TOKEN }} |
added.github/workflows/live-test.yml+814−0View file
| @@ -1,0 +1,814 @@ | ||
| 1 | +name: Live test | |
| 2 | + | |
| 3 | +# End-to-end checks against the real diffinery.com after each deploy: email | |
| 4 | +# sending, web UI features (syntax highlighting, settings pages, view transitions, milestones, variables), hosted Actions jobs in Cloudflare Containers (with Docker builds and services), the container registry, agent sandboxes, | |
| 5 | +# webhooks, scheduled workflows, Git LFS, imports from GitHub, that tokens can't mint tokens, and pull requests (test merge commits, pull_request_target and the merge | |
| 6 | +# queue). Each run works in a throwaway private repository that it | |
| 7 | +# deletes at the end. | |
| 8 | +# | |
| 9 | +# Needs DIFFINERY_LIVE_TOKEN in the production environment: a write-scope token | |
| 10 | +# (Settings -> Tokens) with access to all repositories, for a dedicated account that isn't | |
| 11 | +# a site administrator (give it a quota override). The checks that need a site | |
| 12 | +# administrator are skipped with a notice when the token isn't one's. | |
| 13 | +on: | |
| 14 | + workflow_dispatch: | |
| 15 | + workflow_run: | |
| 16 | + workflows: [Deploy] | |
| 17 | + types: [completed] | |
| 18 | + branches: [main] | |
| 19 | + | |
| 20 | +permissions: | |
| 21 | + contents: read | |
| 22 | + # To see whether the Deploy run it follows deployed anything. | |
| 23 | + actions: read | |
| 24 | + | |
| 25 | +concurrency: | |
| 26 | + group: live-test | |
| 27 | + cancel-in-progress: false | |
| 28 | + | |
| 29 | +jobs: | |
| 30 | + live: | |
| 31 | + name: Containers, registry, sandboxes, webhooks, schedules, LFS and pull requests | |
| 32 | + # Runs on the host that deploys production (see the gate in deploy.yml). | |
| 33 | + if: ${{ github.event_name == 'workflow_dispatch' || (github.server_url == 'https://diffinery.com' && github.event.workflow_run.conclusion == 'success') }} | |
| 34 | + runs-on: ubuntu-latest | |
| 35 | + timeout-minutes: 45 | |
| 36 | + environment: production | |
| 37 | + env: | |
| 38 | + DIFFINERY: https://diffinery.com | |
| 39 | + TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }} | |
| 40 | + REPO: live-${{ github.run_id }}-${{ github.run_attempt }} | |
| 41 | + HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} | |
| 42 | + steps: | |
| 43 | + - name: Check the token | |
| 44 | + id: token | |
| 45 | + env: | |
| 46 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| 47 | + DEPLOY_RUN: ${{ github.event.workflow_run.id }} | |
| 48 | + run: | | |
| 49 | + if [ "$GITHUB_EVENT_NAME" = workflow_run ]; then | |
| 50 | + # Deploy's gate skips a commit that is no longer main's tip (the newer commit's run deploys it): | |
| 51 | + # then nothing new is live, and this run would only test the site as it was. | |
| 52 | + deployed=$(curl -fsS "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$DEPLOY_RUN/jobs" -H "Authorization: Bearer $GH_TOKEN" | jq -r '[.jobs[] | select(.name == "Deploy to Cloudflare") | .conclusion][0] // "unknown"' || echo unknown) | |
| 53 | + if [ "$deployed" = skipped ]; then | |
| 54 | + echo "::notice::Deploy deployed nothing for $HEAD_SHA (main has moved on), so there is nothing new to test" | |
| 55 | + echo "skip=true" >> "$GITHUB_OUTPUT" | |
| 56 | + exit 0 | |
| 57 | + fi | |
| 58 | + fi | |
| 59 | + if [ -z "$TOKEN" ]; then | |
| 60 | + # After a deploy of diffinery.com a missing token is an error: a green run would mean nothing was tested. | |
| 61 | + if [ "$GITHUB_EVENT_NAME" = workflow_run ]; then | |
| 62 | + echo "::error::DIFFINERY_LIVE_TOKEN is not set in the production environment, so nothing was tested. Create a write token at $DIFFINERY/settings/tokens and save it as a production environment secret." | |
| 63 | + exit 1 | |
| 64 | + fi | |
| 65 | + echo "::warning::DIFFINERY_LIVE_TOKEN is not set, so the live test is skipped. Create a write token at $DIFFINERY/settings/tokens and save it as a production environment secret." | |
| 66 | + echo "skip=true" >> "$GITHUB_OUTPUT" | |
| 67 | + exit 0 | |
| 68 | + fi | |
| 69 | + login=$(curl -fsS "$DIFFINERY/api/v3/user" -H "Authorization: Bearer $TOKEN" | jq -r .login) | |
| 70 | + echo "OWNER=$login" >> "$GITHUB_ENV" | |
| 71 | + echo "testing as $login in $login/$REPO" | |
| 72 | + | |
| 73 | + - name: Choose the registry check's image sizes | |
| 74 | + if: steps.token.outputs.skip != 'true' | |
| 75 | + run: | | |
| 76 | + set -euo pipefail | |
| 77 | + # Layers over Cloudflare's 100 MB request limit (which hosted runners push through | |
| 78 | + # the registry relay) take minutes to push and pull. They're pushed on manual runs and | |
| 79 | + # whenever the registry, the runner image or this test changed since the last | |
| 80 | + # successful live test; other deploys push small images through the same path. | |
| 81 | + full=true | |
| 82 | + if [ "$GITHUB_EVENT_NAME" = workflow_run ]; then | |
| 83 | + api() { curl -fsS "$DIFFINERY/api/v3/repos/$GITHUB_REPOSITORY$1" -H "Authorization: Bearer $TOKEN"; } | |
| 84 | + last=$(api "/actions/runs?workflow=live-test.yml&status=success&per_page=1" | jq -r '.workflow_runs[0].head_sha // empty' || true) | |
| 85 | + if [ -n "$last" ] && files=$(api "/compare/$last...$HEAD_SHA" | jq -r '.files[].filename'); then | |
| 86 | + grep -qE '^(apps/diffinery/src/registry/|containers/runner/|\.github/workflows/live-test\.yml$)' <<<"$files" || full=false | |
| 87 | + echo "changed since $last: $(wc -l <<<"$files") files" | |
| 88 | + fi | |
| 89 | + fi | |
| 90 | + if [ "$full" = true ]; then | |
| 91 | + echo "BIG=150000000" >> "$GITHUB_ENV" | |
| 92 | + echo "BUILDX=120000000" >> "$GITHUB_ENV" | |
| 93 | + else | |
| 94 | + echo "BIG=5000000" >> "$GITHUB_ENV" | |
| 95 | + echo "BUILDX=4000000" >> "$GITHUB_ENV" | |
| 96 | + fi | |
| 97 | + echo "registry check: full=$full" | |
| 98 | + | |
| 99 | + - name: Profile picture upload (WASM resize in the Worker) | |
| 100 | + if: steps.token.outputs.skip != 'true' | |
| 101 | + run: | | |
| 102 | + set -euo pipefail | |
| 103 | + av="$DIFFINERY/api/avatars/$OWNER" | |
| 104 | + # Keep the account's current picture, if it has one, to put it back afterwards. | |
| 105 | + had=$(curl -fsS -o before.webp -w '%{content_type}' "$av?s=256&v=1") | |
| 106 | + restore() { | |
| 107 | + if [ "$had" = image/webp ]; then | |
| 108 | + curl -fsS -X PUT "$DIFFINERY/api/v3/user/avatar" -H "Authorization: Bearer $TOKEN" -H "content-type: image/webp" --data-binary @before.webp | jq -r '"restored the previous picture: \(.avatar_url)"' | |
| 109 | + else | |
| 110 | + curl -fsS -o /dev/null -X DELETE "$DIFFINERY/api/v3/user/avatar" -H "Authorization: Bearer $TOKEN" | |
| 111 | + fi | |
| 112 | + } | |
| 113 | + trap restore EXIT | |
| 114 | + # A 600×400 PNG gradient; the server crops it to a square and makes small WebPs. | |
| 115 | + python3 - <<'EOF' | |
| 116 | + import struct, zlib | |
| 117 | + w, h = 600, 400 | |
| 118 | + rows = b"".join(b"\0" + bytes(c for x in range(w) for c in (x * 255 // w, y * 255 // h, 128)) for y in range(h)) | |
| 119 | + chunk = lambda t, d: struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d)) | |
| 120 | + open("avatar.png", "wb").write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b"")) | |
| 121 | + EOF | |
| 122 | + url=$(curl -fsS -X PUT "$DIFFINERY/api/v3/user/avatar" -H "Authorization: Bearer $TOKEN" -H "content-type: image/png" --data-binary @avatar.png | jq -r .avatar_url) | |
| 123 | + echo "uploaded: $url" | |
| 124 | + v=${url##*v=} | |
| 125 | + type=$(curl -fsS -o small.webp -w '%{content_type}' "$av?s=64&v=$v") | |
| 126 | + [ "$type" = image/webp ] || { echo "::error::expected image/webp, got $type"; exit 1; } | |
| 127 | + python3 - <<'EOF' | |
| 128 | + import struct, sys | |
| 129 | + b = open("small.webp", "rb").read() | |
| 130 | + ok = b[:4] == b"RIFF" and b[8:12] == b"WEBP" | |
| 131 | + if b[12:16] == b"VP8 ": | |
| 132 | + size = (struct.unpack("<H", b[26:28])[0] & 0x3FFF, struct.unpack("<H", b[28:30])[0] & 0x3FFF) | |
| 133 | + elif b[12:16] == b"VP8X": | |
| 134 | + size = (int.from_bytes(b[24:27], "little") + 1, int.from_bytes(b[27:30], "little") + 1) | |
| 135 | + else: | |
| 136 | + size = None | |
| 137 | + print(f"64 px avatar: {len(b)} bytes, {size}") | |
| 138 | + if not ok or size != (64, 64) or len(b) > 5000: | |
| 139 | + print("::error::the 64 px avatar is not a 64x64 WebP under 5 KB"); sys.exit(1) | |
| 140 | + EOF | |
| 141 | + curl -fsS -X DELETE "$DIFFINERY/api/v3/user/avatar" -H "Authorization: Bearer $TOKEN" >/dev/null | |
| 142 | + # A `v` that is not current bypasses the edge cache, so this sees the removal from any colo. | |
| 143 | + type=$(curl -fsS -o /dev/null -w '%{content_type}' "$av?s=64&v=1") | |
| 144 | + [[ "$type" == image/svg+xml* ]] || { echo "::error::after removal expected the initials SVG, got $type"; exit 1; } | |
| 145 | + echo "removed: initials again ($type)" | |
| 146 | + | |
| 147 | + - name: Public docs are served without signing in | |
| 148 | + run: | | |
| 149 | + set -euo pipefail | |
| 150 | + check() { # path, content type, text it must contain | |
| 151 | + body=$(curl -fsS -D /tmp/h "$DIFFINERY$1") | |
| 152 | + grep -qi "content-type: $2" /tmp/h || { echo "::error::$1 is not $2"; exit 1; } | |
| 153 | + grep -qF -- "$3" <<<"$body" || { echo "::error::$1 doesn't contain: $3"; exit 1; } | |
| 154 | + echo "$1 ok ($(wc -c <<<"$body") bytes)" | |
| 155 | + } | |
| 156 | + check /docs "text/html" "Overview" | |
| 157 | + check /docs/actions/workflow-syntax "text/html" "View as Markdown" | |
| 158 | + check /docs/quickstart.md "text/markdown" "# Quickstart" | |
| 159 | + check /llms.txt "text/plain" "/docs/actions/workflow-syntax.md" | |
| 160 | + check /llms-full.txt "text/plain" "# REST API reference" | |
| 161 | + check /docs/api/openapi.json "application/json" '"openapi": "3.1.0"' | |
| 162 | + check /-/cli/diffinery.mjs "text/javascript" "#!/usr/bin/env node" | |
| 163 | + md=$(curl -fsS -H "Accept: text/markdown" "$DIFFINERY/docs/agents/mcp") | |
| 164 | + grep -q "^# MCP" <<<"$md" || { echo "::error::Accept: text/markdown didn't return Markdown"; exit 1; } | |
| 165 | + curl -fsS "$DIFFINERY/-/cli/diffinery.mjs" -o /tmp/diffinery.mjs && node /tmp/diffinery.mjs --version | |
| 166 | + | |
| 167 | + - name: Email sending is configured | |
| 168 | + if: steps.token.outputs.skip != 'true' | |
| 169 | + run: | | |
| 170 | + set -euo pipefail | |
| 171 | + s=$(curl -sS -w '\n%{http_code}' "$DIFFINERY/api/v3/diffinery/admin/email" -H "Authorization: Bearer $TOKEN") | |
| 172 | + code=${s##*$'\n'}; s=${s%$'\n'*} | |
| 173 | + # Only a site administrator's token reads the email settings, and the live-test account isn't one. | |
| 174 | + if [ "$code" = 403 ]; then echo "::notice::skipped: the live-test token isn't a site administrator's"; exit 0; fi | |
| 175 | + [ "$code" = 200 ] || { echo "::error::/diffinery/admin/email answered $code"; exit 1; } | |
| 176 | + echo "$s" | jq '{sending, queue, from, last_24h}' | |
| 177 | + [ "$(jq -r .sending <<<"$s")" = true ] || { echo "::error::the Worker has no Email Sending binding"; exit 1; } | |
| 178 | + [ "$(jq -r .queue <<<"$s")" = true ] || { echo "::error::the Worker has no mail queue binding"; exit 1; } | |
| 179 | + failed=$(jq -r .last_24h.failed <<<"$s") | |
| 180 | + [ "$failed" = 0 ] || echo "::warning::$failed emails failed in the last 24 hours; see Settings -> Email in site administration" | |
| 181 | + | |
| 182 | + - name: Create a throwaway repository with a webhook | |
| 183 | + if: steps.token.outputs.skip != 'true' | |
| 184 | + run: | | |
| 185 | + set -euo pipefail | |
| 186 | + api() { curl -fsS -X "$1" "$DIFFINERY/api/v3$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" ${3:+--data "$3"}; } | |
| 187 | + api POST /user/repos "{\"name\":\"$REPO\",\"private\":true}" >/dev/null | |
| 188 | + # Webhooks may only reach public hosts other than Diffinery itself; the push's delivery goes to example.com. | |
| 189 | + code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$DIFFINERY/api/v3/repos/$OWNER/$REPO/hooks" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data "{\"config\":{\"url\":\"$DIFFINERY/healthz\"},\"events\":[\"push\"]}") | |
| 190 | + [ "$code" = 422 ] || { echo "::error::a webhook to Diffinery itself answered $code, not 422"; exit 1; } | |
| 191 | + api POST "/repos/$OWNER/$REPO/hooks" "{\"config\":{\"url\":\"https://example.com/diffinery-live-test\"},\"events\":[\"push\"]}" >/dev/null | |
| 192 | + echo "created $OWNER/$REPO" | |
| 193 | + | |
| 194 | + - name: Wait for jobs to start on the deployed runner image | |
| 195 | + if: steps.token.outputs.skip != 'true' | |
| 196 | + run: | | |
| 197 | + set -euo pipefail | |
| 198 | + # A deploy's container rollout updates instances over a while after it starts, and an | |
| 199 | + # instance not yet updated starts jobs on the previous runner image. Jobs report their | |
| 200 | + # image (`runner.image`), so a probe workflow runs until three jobs in a row land on the | |
| 201 | + # image the deploy set (/meta's runner_image) before the Docker and registry checks. | |
| 202 | + api() { curl -fsS -X "$1" "$DIFFINERY/api/v3$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" ${3:+--data "$3"}; } | |
| 203 | + expected=$(api GET /meta | jq -r '.runner_image // empty') | |
| 204 | + if [ -z "$expected" ]; then | |
| 205 | + echo "::warning::/meta doesn't name the deployed runner image, so jobs aren't checked against it" | |
| 206 | + exit 0 | |
| 207 | + fi | |
| 208 | + echo "DEPLOYED_RUNNER_IMAGE=$expected" >> "$GITHUB_ENV" | |
| 209 | + mkdir -p src/.github/workflows && cd src | |
| 210 | + git init -q -b main | |
| 211 | + git config user.name "Live test" && git config user.email "live-test@diffinery.com" | |
| 212 | + cat > .github/workflows/probe.yml <<'EOF' | |
| 213 | + on: workflow_dispatch | |
| 214 | + jobs: | |
| 215 | + probe: | |
| 216 | + runs-on: ubuntu-latest | |
| 217 | + strategy: | |
| 218 | + matrix: | |
| 219 | + n: [1, 2, 3] | |
| 220 | + steps: | |
| 221 | + - run: echo "probe ok" | |
| 222 | + EOF | |
| 223 | + git add -A && git commit -q -m "Runner image probe" | |
| 224 | + git push -q "https://x-token:${TOKEN}@${DIFFINERY#https://}/$OWNER/$REPO.git" main | |
| 225 | + deadline=$(( $(date +%s) + 15 * 60 )) | |
| 226 | + while :; do | |
| 227 | + before=$(api GET "/repos/$OWNER/$REPO/actions/runs?event=workflow_dispatch" | jq '.workflow_runs | length') | |
| 228 | + api POST "/repos/$OWNER/$REPO/actions/workflows/probe.yml/dispatches" '{"ref":"main"}' >/dev/null | |
| 229 | + run="" | |
| 230 | + # Up to 10 minutes: a job turned away from a replaced image starts again after 30 s, 1, 2 and 4 minutes. | |
| 231 | + for i in $(seq 1 120); do | |
| 232 | + runs=$(api GET "/repos/$OWNER/$REPO/actions/runs?event=workflow_dispatch") | |
| 233 | + if [ "$(jq '.workflow_runs | length' <<<"$runs")" -gt "$before" ]; then | |
| 234 | + run=$(jq -c '.workflow_runs[0]' <<<"$runs") | |
| 235 | + [ "$(jq -r .status <<<"$run")" = completed ] && break | |
| 236 | + fi | |
| 237 | + sleep 5 | |
| 238 | + done | |
| 239 | + [ -n "$run" ] || { echo "::error::the probe workflow didn't start"; exit 1; } | |
| 240 | + probe=$(api GET "/repos/$OWNER/$REPO/actions/runs/$(jq -r .id <<<"$run")/jobs") | |
| 241 | + images=$(jq -r '.jobs[] | .runner.image // "no image reported"' <<<"$probe") | |
| 242 | + echo "probe jobs ran on: $(sort <<<"$images" | uniq -c | sed 's/^ *//' | paste -sd ','), started again $(jq '[.jobs[].placementTries // 0] | add // 0' <<<"$probe") time(s) in all" | |
| 243 | + [ "$(grep -cxF "$expected" <<<"$images" || true)" = 3 ] && { echo "jobs start on $expected"; exit 0; } | |
| 244 | + if [ "$(date +%s)" -ge "$deadline" ]; then | |
| 245 | + echo "::error::15 minutes after the deploy, jobs still start on a runner image other than the deployed $expected" | |
| 246 | + exit 1 | |
| 247 | + fi | |
| 248 | + sleep 20 | |
| 249 | + done | |
| 250 | + | |
| 251 | + - name: Push workflows and an LFS file | |
| 252 | + id: push | |
| 253 | + if: steps.token.outputs.skip != 'true' | |
| 254 | + run: | | |
| 255 | + set -euo pipefail | |
| 256 | + mkdir -p src/.github/workflows && cd src | |
| 257 | + git init -q -b main | |
| 258 | + git config user.name "Live test" && git config user.email "live-test@diffinery.com" | |
| 259 | + git lfs install --local >/dev/null | |
| 260 | + git lfs track "*.bin" >/dev/null | |
| 261 | + head -c 3145728 /dev/urandom > blob.bin | |
| 262 | + sha256sum blob.bin | cut -d' ' -f1 > ../blob.sha | |
| 263 | + echo "# $REPO" > README.md | |
| 264 | + cat > .github/workflows/ci.yml <<'EOF' | |
| 265 | + on: push | |
| 266 | + jobs: | |
| 267 | + hosted: | |
| 268 | + runs-on: ubuntu-latest | |
| 269 | + strategy: | |
| 270 | + matrix: | |
| 271 | + n: [1, 2] | |
| 272 | + steps: | |
| 273 | + - uses: actions/checkout@v4 | |
| 274 | + - run: echo "live ok ${{ '${{' }} matrix.n }} $(cat README.md)" | |
| 275 | + # Passwordless sudo, as on GitHub's runners (`sudo apt-get install ...` is common). | |
| 276 | + - run: sudo -n true && echo "sudo ok $(sudo -n id -u)" | |
| 277 | + docker: | |
| 278 | + runs-on: ubuntu-latest | |
| 279 | + services: | |
| 280 | + cache: | |
| 281 | + image: redis:7-alpine | |
| 282 | + ports: ["6379"] | |
| 283 | + options: --health-cmd "redis-cli ping" --health-interval 1s --health-retries 30 | |
| 284 | + steps: | |
| 285 | + - run: | | |
| 286 | + printf 'FROM alpine:3.20\nRUN echo built > /msg\nCMD ["cat", "/msg"]\n' > Dockerfile | |
| 287 | + docker build -q -t live-image . | |
| 288 | + echo "docker ok $(docker run --rm live-image)" | |
| 289 | + exec 3<>/dev/tcp/127.0.0.1/${{ '${{' }} job.services.cache.ports['6379'] }} | |
| 290 | + printf 'PING\r\n' >&3 | |
| 291 | + read -r line <&3 | |
| 292 | + echo "service ok ${line%$'\r'}" | |
| 293 | + # Container registry: a layer pushed with the job's GITHUB_TOKEN (over Cloudflare's | |
| 294 | + # 100 MB request limit on full runs), plus the docker/*-action route most ghcr.io | |
| 295 | + # workflows use. __BIG__ and __BUILDX__ are the layer sizes. | |
| 296 | + publish: | |
| 297 | + runs-on: ubuntu-latest | |
| 298 | + permissions: | |
| 299 | + packages: write | |
| 300 | + steps: | |
| 301 | + - run: | | |
| 302 | + image="diffinery.com/${GITHUB_REPOSITORY,,}" | |
| 303 | + echo "${{ '${{' }} secrets.GITHUB_TOKEN }}" | docker login diffinery.com -u "${{ '${{' }} github.actor }}" --password-stdin | |
| 304 | + head -c __BIG__ /dev/urandom > big.bin | |
| 305 | + sha256sum big.bin | cut -d' ' -f1 > big.sha | |
| 306 | + printf 'FROM scratch\nCOPY big.bin big.sha /\n' > Dockerfile | |
| 307 | + docker build -q -t "$image:live" . | |
| 308 | + docker push "$image:live" | |
| 309 | + echo "pushed $(cat big.sha)" | |
| 310 | + buildx: | |
| 311 | + runs-on: ubuntu-latest | |
| 312 | + permissions: | |
| 313 | + packages: write | |
| 314 | + steps: | |
| 315 | + - run: | | |
| 316 | + echo "IMAGE=diffinery.com/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" | |
| 317 | + head -c __BUILDX__ /dev/urandom > big.bin | |
| 318 | + printf 'FROM scratch\nCOPY big.bin /\n' > Dockerfile | |
| 319 | + - uses: docker/setup-buildx-action@v3 | |
| 320 | + - uses: docker/login-action@v3 | |
| 321 | + with: | |
| 322 | + registry: diffinery.com | |
| 323 | + username: ${{ '${{' }} github.actor }} | |
| 324 | + password: ${{ '${{' }} secrets.GITHUB_TOKEN }} | |
| 325 | + - uses: docker/build-push-action@v6 | |
| 326 | + with: | |
| 327 | + context: . | |
| 328 | + push: true | |
| 329 | + tags: ${{ '${{' }} env.IMAGE }}:buildx | |
| 330 | + pull: | |
| 331 | + needs: [publish, buildx] | |
| 332 | + runs-on: ubuntu-latest | |
| 333 | + permissions: | |
| 334 | + packages: read | |
| 335 | + steps: | |
| 336 | + - run: | | |
| 337 | + image="diffinery.com/${GITHUB_REPOSITORY,,}" | |
| 338 | + echo "${{ '${{' }} secrets.GITHUB_TOKEN }}" | docker login diffinery.com -u "${{ '${{' }} github.actor }}" --password-stdin | |
| 339 | + docker pull -q "$image:live" | |
| 340 | + id=$(docker create "$image:live" true) | |
| 341 | + docker cp "$id:/big.bin" big.bin && docker cp "$id:/big.sha" big.sha | |
| 342 | + [ "$(sha256sum big.bin | cut -d' ' -f1)" = "$(cat big.sha)" ] && echo "registry ok $(stat -c %s big.bin) bytes" | |
| 343 | + docker pull -q "$image:buildx" | |
| 344 | + id=$(docker create "$image:buildx" true) | |
| 345 | + docker cp "$id:/big.bin" buildx.bin | |
| 346 | + echo "buildx ok $(stat -c %s buildx.bin) bytes" | |
| 347 | + EOF | |
| 348 | + cat > .github/workflows/tick.yml <<'EOF' | |
| 349 | + on: | |
| 350 | + schedule: | |
| 351 | + - cron: "*/5 * * * *" | |
| 352 | + jobs: | |
| 353 | + tick: | |
| 354 | + runs-on: ubuntu-latest | |
| 355 | + steps: | |
| 356 | + - run: echo "scheduled ok" | |
| 357 | + EOF | |
| 358 | + sed -i "s/__BIG__/$BIG/g; s/__BUILDX__/$BUILDX/g" .github/workflows/ci.yml | |
| 359 | + git add -A && git commit -q -m "Live test" | |
| 360 | + git push -q "https://x-token:${TOKEN}@${DIFFINERY#https://}/$OWNER/$REPO.git" main | |
| 361 | + git lfs ls-files | |
| 362 | + | |
| 363 | + - name: Web UI features (highlighting, settings pages, view transitions, base permission, invitations) | |
| 364 | + if: steps.token.outputs.skip != 'true' | |
| 365 | + run: | | |
| 366 | + set -euo pipefail | |
| 367 | + page() { curl -fsS "$DIFFINERY$1" -H "Authorization: Bearer $TOKEN"; } | |
| 368 | + has() { grep -qF -- "$2" <<<"$1" || { echo "::error::$3"; exit 1; }; } | |
| 369 | + html=$(page "/$OWNER/$REPO/blob/main/.github/workflows/ci.yml") | |
| 370 | + has "$html" '<td class="sh">' "the workflow file is not syntax highlighted" | |
| 371 | + has "$html" "--shiki-dark" "highlighted code has no dark theme colours" | |
| 372 | + has "$html" "@view-transition{navigation:auto}" "pages don't opt in to view transitions" | |
| 373 | + has "$html" "prefers-reduced-motion:no-preference" "view transitions ignore reduced motion" | |
| 374 | + has "$(page /settings/appearance)" 'name="appearance"' "Settings -> Appearance has no theme choice" | |
| 375 | + has "$(page /notifications)" 'href="/settings/notifications"' "the notifications page doesn't link to notification settings" | |
| 376 | + code=$(curl -sS -o /tmp/out -w '%{http_code}' -X POST "$DIFFINERY/api/v3/user/repos" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data "{\"name\":\"$REPO-internal\",\"visibility\":\"internal\"}") | |
| 377 | + [ "$code" = 422 ] || { echo "::error::visibility internal got $code, expected 422"; curl -sS -o /dev/null -X DELETE "$DIFFINERY/api/v3/repos/$OWNER/$REPO-internal" -H "Authorization: Bearer $TOKEN"; exit 1; } | |
| 378 | + org=$(page /api/v3/user/orgs | jq -r '.[0].login // empty') | |
| 379 | + if [ -n "$org" ]; then | |
| 380 | + perm=$(page "/api/v3/orgs/$org" | jq -r '.default_repository_permission // empty') | |
| 381 | + case "$perm" in none|read|write|admin) echo "$org's base permission: $perm" ;; *) echo "::error::GET /orgs/$org has no default_repository_permission"; exit 1 ;; esac | |
| 382 | + # A malformed rename is refused before anything is written (422 for owners, 403 for everyone else). | |
| 383 | + code=$(curl -sS -o /tmp/out -w '%{http_code}' -X PATCH "$DIFFINERY/api/v3/orgs/$org" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data '{"name":123}') | |
| 384 | + case "$code" in 422|403) ;; *) echo "::error::PATCH /orgs/$org with a numeric name got $code"; cat /tmp/out; exit 1 ;; esac | |
| 385 | + fi | |
| 386 | + page "/api/v3/repos/$OWNER/$REPO/invitations" | jq -e 'type == "array"' >/dev/null || { echo "::error::GET /repos/$OWNER/$REPO/invitations doesn't list pending invitations"; exit 1; } | |
| 387 | + tools=$(curl -fsS -X POST "$DIFFINERY/mcp" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" -H "accept: application/json, text/event-stream" --data '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq -r '.result.tools[].name') | |
| 388 | + for t in get_organization list_invitations revoke_invitation; do grep -qx "$t" <<<"$tools" || { echo "::error::the MCP server has no $t tool"; exit 1; }; done | |
| 389 | + echo "highlighting, Appearance, notification settings link, view transitions, visibility, organization and invitation checks passed" | |
| 390 | + | |
| 391 | + - name: Diffy placeholders, Actions settings, milestones and variables | |
| 392 | + if: steps.token.outputs.skip != 'true' | |
| 393 | + run: | | |
| 394 | + set -euo pipefail | |
| 395 | + page() { curl -fsS "$DIFFINERY$1" -H "Authorization: Bearer $TOKEN"; } | |
| 396 | + api() { curl -fsS -X "$1" "$DIFFINERY/api/v3$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" ${3:+--data "$3"}; } | |
| 397 | + code() { curl -sS -o /tmp/out -w '%{http_code}' -X "$1" "$DIFFINERY/api/v3$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" ${3:+--data "$3"}; } | |
| 398 | + fail() { echo "::error::$1"; cat /tmp/out 2>/dev/null || true; exit 1; } | |
| 399 | + has() { grep -qF -- "$2" <<<"$1" || fail "$3"; } | |
| 400 | + R="/repos/$OWNER/$REPO" | |
| 401 | + has "$(page /new/import)" 'diffy/hello-world' "the importer's placeholder isn't Diffy" | |
| 402 | + has "$(page "/$OWNER/$REPO/settings/actions")" 'Allow GitHub Actions to create and approve pull requests' "repository settings have no Actions page" | |
| 403 | + # Milestones: created closed, checked before an issue edit is saved, shown in the response, and filterable. | |
| 404 | + [ "$(api POST "$R/milestones" '{"title":"Shipped","state":"closed"}' | jq -r .state)" = closed ] || fail "POST milestones ignored state" | |
| 405 | + ms=$(api POST "$R/milestones" '{"title":"Live v1"}' | jq -r .number) | |
| 406 | + n=$(api POST "$R/issues" '{"title":"Planned"}' | jq -r .number) | |
| 407 | + [ "$(code PATCH "$R/issues/$n" '{"title":"Renamed","milestone":999999}')" = 422 ] || fail "an unknown milestone wasn't refused with 422" | |
| 408 | + [ "$(api GET "$R/issues/$n" | jq -r .title)" = Planned ] || fail "a refused milestone edit saved the title" | |
| 409 | + [ "$(api PATCH "$R/issues/$n" "{\"milestone\":$ms}" | jq -r .milestone.number)" = "$ms" ] || fail "PATCH issue doesn't return the new milestone" | |
| 410 | + [ "$(api GET "$R/issues?milestone=$ms&state=all" | jq -r '[.[].number] | join(",")')" = "$n" ] || fail "issues aren't filtered by milestone" | |
| 411 | + loc=$(curl -sS -o /dev/null -w '%{redirect_url}' "$DIFFINERY/$OWNER/$REPO/milestone/$ms" -H "Authorization: Bearer $TOKEN") | |
| 412 | + has "$loc" "/issues?milestone=$ms" "the milestone link doesn't open its issue list" | |
| 413 | + # Repository variable deletes ignore letter case. | |
| 414 | + [ "$(code PUT "$R/actions/variables/LIVE_CASE" '{"value":"x"}')" = 204 ] || fail "couldn't set a repository variable" | |
| 415 | + [ "$(code DELETE "$R/actions/variables/live_case")" = 204 ] || fail "couldn't delete a repository variable" | |
| 416 | + [ "$(code GET "$R/actions/variables/LIVE_CASE")" = 404 ] || fail "deleting a variable in lower case left it in place" | |
| 417 | + org=$(page /api/v3/user/orgs | jq -r '.[0].login // empty') | |
| 418 | + if [ -n "$org" ]; then | |
| 419 | + # Deleting an organization variable or secret that doesn't exist is a 404 (403 for someone who isn't an admin). | |
| 420 | + for path in variables secrets; do | |
| 421 | + c=$(code DELETE "/orgs/$org/actions/$path/LIVE_NEVER_SET_$GITHUB_RUN_ID") | |
| 422 | + case "$c" in 404|403) ;; *) fail "DELETE of a missing organization $path entry got $c" ;; esac | |
| 423 | + done | |
| 424 | + fi | |
| 425 | + echo "Diffy placeholders, Actions settings page, milestone and variable checks passed" | |
| 426 | + | |
| 427 | + - name: Hosted Actions jobs (and Docker) run in Cloudflare Containers | |
| 428 | + if: steps.token.outputs.skip != 'true' | |
| 429 | + run: | | |
| 430 | + set -euo pipefail | |
| 431 | + api() { curl -fsS "$DIFFINERY/api/v3$1" -H "Authorization: Bearer $TOKEN"; } | |
| 432 | + for i in $(seq 1 90); do | |
| 433 | + run=$(api "/repos/$OWNER/$REPO/actions/runs?event=push" | jq -c '.workflow_runs[0] // empty') | |
| 434 | + [ -n "$run" ] && [ "$(jq -r .status <<<"$run")" = completed ] && break | |
| 435 | + sleep 10 | |
| 436 | + done | |
| 437 | + echo "$run" | jq '{id, status, conclusion}' | |
| 438 | + id=$(jq -r .id <<<"$run") | |
| 439 | + jobs=$(api "/repos/$OWNER/$REPO/actions/runs/$id/jobs") | |
| 440 | + # placementTries: times a job went back in line (no machine, or a runner on a replaced image turned away). | |
| 441 | + # restarts: times it started over because the platform replaced its runner before its steps ran. | |
| 442 | + jq -r '.jobs[] | " \(.name): \(.conclusion)\(if .failure then " - " + (.failure.message // "") else "" end)\(try " (\((.completed_at | fromdateiso8601) - (.started_at | fromdateiso8601))s)" catch "")\(if (.placementTries // 0) > 0 then ", started again \(.placementTries) time(s)" else "" end)\(if (.restarts // 0) > 0 then ", restarted \(.restarts) time(s) after its runner was replaced" else "" end)"' <<<"$jobs" | |
| 443 | + if [ "$(jq -r .conclusion <<<"$run")" != success ]; then | |
| 444 | + for job in $(jq -r '.jobs[] | select(.conclusion == "failure" or .conclusion == "timed_out") | .id' <<<"$jobs"); do | |
| 445 | + echo "::group::Last log lines of job $job" | |
| 446 | + api "/repos/$OWNER/$REPO/actions/jobs/$job/logs" | tail -n 80 || true | |
| 447 | + echo "::endgroup::" | |
| 448 | + done | |
| 449 | + echo "::error::the push run did not succeed" | |
| 450 | + exit 1 | |
| 451 | + fi | |
| 452 | + if [ -n "${DEPLOYED_RUNNER_IMAGE:-}" ]; then | |
| 453 | + # Every job ran the deployed runner, so these checks test what was just deployed. | |
| 454 | + stale=$(jq -r --arg want "$DEPLOYED_RUNNER_IMAGE" '.jobs[] | select(.runner.image != $want) | "\(.name) on \(.runner.image // "no image reported")"' <<<"$jobs") | |
| 455 | + [ -z "$stale" ] || { echo "::error::jobs ran on a runner image other than the deployed $DEPLOYED_RUNNER_IMAGE: $(paste -sd ';' <<<"$stale")"; exit 1; } | |
| 456 | + echo "every job ran on $DEPLOYED_RUNNER_IMAGE" | |
| 457 | + fi | |
| 458 | + for job in $(jq -r '.jobs[] | select(.name | startswith("hosted")) | .id' <<<"$jobs"); do | |
| 459 | + log=$(api "/repos/$OWNER/$REPO/actions/jobs/$job/logs") | |
| 460 | + grep -F "live ok" <<<"$log" | grep -F "# $REPO" || { echo "::error::job $job did not print its line (checkout or run step failed)"; exit 1; } | |
| 461 | + grep -F "sudo ok 0" <<<"$log" || { echo "::error::job $job could not run sudo without a password"; exit 1; } | |
| 462 | + done | |
| 463 | + job=$(jq -r '.jobs[] | select(.name == "docker") | .id' <<<"$jobs") | |
| 464 | + log=$(api "/repos/$OWNER/$REPO/actions/jobs/$job/logs") | |
| 465 | + grep -F "docker ok built" <<<"$log" || { echo "::error::docker build or run failed on a hosted runner"; exit 1; } | |
| 466 | + grep -F "service ok +PONG" <<<"$log" || { echo "::error::the redis service container was not reachable"; exit 1; } | |
| 467 | + job=$(jq -r '.jobs[] | select(.name == "pull") | .id' <<<"$jobs") | |
| 468 | + log=$(api "/repos/$OWNER/$REPO/actions/jobs/$job/logs") | |
| 469 | + grep -F "registry ok $BIG bytes" <<<"$log" || { echo "::error::the image pushed with a $BIG-byte layer did not come back intact from the registry"; exit 1; } | |
| 470 | + grep -F "buildx ok $BUILDX bytes" <<<"$log" || { echo "::error::the image pushed by docker/build-push-action did not come back from the registry"; exit 1; } | |
| 471 | + api "/users/$OWNER/packages/container/${REPO,,}/versions" | jq -c '.[] | {tags: .metadata.container.tags, size, platforms}' | |
| 472 | + | |
| 473 | + - name: An organization's packages page keeps its tabs | |
| 474 | + if: steps.token.outputs.skip != 'true' | |
| 475 | + run: | | |
| 476 | + set -euo pipefail | |
| 477 | + org=$(curl -fsS "$DIFFINERY/api/v3/user/orgs" -H "Authorization: Bearer $TOKEN" | jq -r '.[0].login // empty') | |
| 478 | + [ -n "$org" ] || { echo "$OWNER is in no organization; skipped"; exit 0; } | |
| 479 | + html=$(curl -fsS "$DIFFINERY/$org/-/packages" -H "Authorization: Bearer $TOKEN") | |
| 480 | + grep -qF "<a href=\"/$org/-/packages\" class=\"on\">" <<<"$html" || { echo "::error::$org's packages page lost the organization's tab bar"; exit 1; } | |
| 481 | + grep -qF "href=\"/$org?tab=repositories\"" <<<"$html" || { echo "::error::$org's packages page has no Repositories tab"; exit 1; } | |
| 482 | + echo "$org's packages page shows the organization's tabs with Packages selected" | |
| 483 | + | |
| 484 | + - name: Git LFS round trip | |
| 485 | + if: steps.token.outputs.skip != 'true' | |
| 486 | + run: | | |
| 487 | + set -euo pipefail | |
| 488 | + git clone -q "https://x-token:${TOKEN}@${DIFFINERY#https://}/$OWNER/$REPO.git" clone | |
| 489 | + [ "$(sha256sum clone/blob.bin | cut -d' ' -f1)" = "$(cat blob.sha)" ] || { echo "::error::the LFS file came back different"; exit 1; } | |
| 490 | + echo "LFS file matches ($(stat -c %s clone/blob.bin) bytes)" | |
| 491 | + | |
| 492 | + - name: Webhook delivered | |
| 493 | + if: steps.token.outputs.skip != 'true' | |
| 494 | + run: | | |
| 495 | + set -euo pipefail | |
| 496 | + for i in $(seq 1 30); do | |
| 497 | + hook=$(curl -fsS "$DIFFINERY/api/v3/repos/$OWNER/$REPO/hooks" -H "Authorization: Bearer $TOKEN" | jq -c '.[0]') | |
| 498 | + # GitHub's hook shape: the target is config.url, the last delivery's status last_response.code. | |
| 499 | + [ "$(jq -r '.last_response.code // empty' <<<"$hook")" != "" ] && break | |
| 500 | + sleep 5 | |
| 501 | + done | |
| 502 | + echo "$hook" | jq '{target: .config.url, last_response, updated_at}' | |
| 503 | + [ "$(jq -r '.last_response.code // empty' <<<"$hook")" != "" ] || { echo "::error::the push webhook was never delivered"; exit 1; } | |
| 504 | + | |
| 505 | + - name: Tokens can't mint tokens (API or web forms) | |
| 506 | + if: steps.token.outputs.skip != 'true' | |
| 507 | + run: | | |
| 508 | + set -euo pipefail | |
| 509 | + # Revokes anything a failed check minted, then fails. | |
| 510 | + minted() { echo "::error::$1"; curl -fsS "$DIFFINERY/api/v3/diffinery/tokens" -H "Authorization: Bearer $TOKEN" | jq -r '.[] | select(.name == "live-test escalation") | .id' | while read -r id; do curl -sS -o /dev/null -X DELETE "$DIFFINERY/api/v3/diffinery/tokens/$id" -H "Authorization: Bearer $TOKEN"; done; exit 1; } | |
| 511 | + code=$(curl -sS -o /tmp/out -w '%{http_code}' -X POST "$DIFFINERY/api/v3/diffinery/tokens" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data '{"name":"live-test escalation","scopes":["read"],"expires_in_days":1}') | |
| 512 | + [ "$code" = 403 ] || minted "an admin token minted a token through the API ($code)" | |
| 513 | + grep -q "browser session" /tmp/out || minted "unexpected refusal: $(cat /tmp/out)" | |
| 514 | + code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$DIFFINERY/api/v3/diffinery/agents" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data '{"login":"live-test-escalation"}') | |
| 515 | + [ "$code" = 403 ] || { echo "::error::an admin token created an agent ($code); disable live-test-escalation by hand"; exit 1; } | |
| 516 | + # A web form with a token and a forged double-submit CSRF pair. | |
| 517 | + code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$DIFFINERY/settings/tokens" -H "Authorization: Bearer $TOKEN" -H "cookie: dfy_csrf=forged" --data "_csrf=forged&name=live-test%20escalation&scope=read&days=1") | |
| 518 | + [ "$code" = 403 ] || minted "a token submitted the web token form ($code)" | |
| 519 | + echo "tokens can't mint tokens or agents, through the API or the web forms" | |
| 520 | + | |
| 521 | + - name: Agent sandbox | |
| 522 | + if: steps.token.outputs.skip != 'true' | |
| 523 | + run: | | |
| 524 | + set -euo pipefail | |
| 525 | + base="$DIFFINERY/api/v3/repos/$OWNER/$REPO/diffinery/sandboxes" | |
| 526 | + id=$(curl -fsS --retry 1 --retry-delay 15 -X POST "$base" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data '{}' | jq -r .id) | |
| 527 | + echo "sandbox $id" | |
| 528 | + for i in $(seq 1 40); do | |
| 529 | + out=$(curl -sS -X POST "$base/$id/exec" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data '{"cmd":"cat README.md && git lfs ls-files | wc -l"}' || true) | |
| 530 | + jq -e '.exitCode == 0' >/dev/null 2>&1 <<<"$out" && break | |
| 531 | + sleep 6 | |
| 532 | + done | |
| 533 | + echo "$out" | jq '{exitCode, stdout}' | |
| 534 | + curl -sS -X POST "$base/$id/close" -H "Authorization: Bearer $TOKEN" >/dev/null || true | |
| 535 | + jq -e --arg r "# $REPO" '.exitCode == 0 and (.stdout | contains($r))' >/dev/null <<<"$out" || { echo "::error::the sandbox did not run the command in the repository"; exit 1; } | |
| 536 | + | |
| 537 | + # Owned by the agentic thread: proves the agent jails (containers/runner/src/jail.ts) on Cloudflare: a terminal | |
| 538 | + # task's, and a headless run's (phase 5d), which reaches Diffinery only at its model endpoints. | |
| 539 | + - name: Agent jails (bubblewrap, no network but the allowlist proxy and the model proxy) | |
| 540 | + if: steps.token.outputs.skip != 'true' | |
| 541 | + run: | | |
| 542 | + set -euo pipefail | |
| 543 | + base="$DIFFINERY/api/v3/repos/$OWNER/$REPO/diffinery/sandboxes" | |
| 544 | + id="" | |
| 545 | + close() { if [ -n "$id" ]; then curl -sS --max-time 60 -X POST "$base/$id/close" -H "Authorization: Bearer $TOKEN" >/dev/null || true; fi; id=""; } | |
| 546 | + trap close EXIT | |
| 547 | + run() { curl -sS --max-time "${2:-240}" -X POST "$base/$id/exec" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data "$(jq -n --arg c "$1" '{cmd: $c, timeoutMs: 180000}')"; } | |
| 548 | + # One create (no retry: a retried POST could open a second sandbox nothing closes), then wait for it to answer. | |
| 549 | + open() { | |
| 550 | + close | |
| 551 | + id=$(curl -sS --max-time 240 -X POST "$base" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data '{}' | jq -r '.id // empty' || true) | |
| 552 | + [ -n "$id" ] || { echo "::error::could not open a sandbox for the jail check"; exit 1; } | |
| 553 | + for i in $(seq 1 30); do | |
| 554 | + jq -e '.exitCode == 0' >/dev/null 2>&1 <<<"$(run true 20 || true)" && return 0 | |
| 555 | + sleep 6 | |
| 556 | + done | |
| 557 | + echo "::error::the sandbox never became ready" | |
| 558 | + exit 1 | |
| 559 | + } | |
| 560 | + # Sandboxes roll out on their own: one opened right after a deploy can still be on the previous image. | |
| 561 | + jail=no | |
| 562 | + for attempt in 1 2 3 4 5; do | |
| 563 | + open | |
| 564 | + # A runner with headless jails asks for the run's model token; an older one ignores --egress. | |
| 565 | + has=$(run 'mkdir -p ../probe ../probe.out; [ -n "${DIFFINERY_JAIL:-}" ] && [ -x "$DIFFINERY_JAIL" ] && "$DIFFINERY_JAIL" agent --dir ../probe --out ../probe.out --egress model -- true 2>&1 | grep -q DIFFINERY_MODEL_TOKEN && echo yes || echo no' 20 | jq -r '.stdout // empty' || true) | |
| 566 | + if [ "$has" = yes ]; then jail=yes; break; fi | |
| 567 | + echo "sandbox $id is on a runner image without the jail; trying another in a minute" | |
| 568 | + close | |
| 569 | + sleep 60 | |
| 570 | + done | |
| 571 | + [ "$jail" = yes ] || { echo "::error::the sandbox started on a runner image without the jail (rollout not finished?)"; exit 1; } | |
| 572 | + inside='echo "direct=$(node -e "require(\"net\").connect(443, \"1.1.1.1\").on(\"connect\", () => (console.log(\"open\"), process.exit())).on(\"error\", (e) => (console.log(e.code), process.exit()))")" | |
| 573 | + echo "other=$(curl -sS -m 20 -o /dev/null -w "%{http_connect}" https://example.com 2>/dev/null || true)" | |
| 574 | + echo "sni=$(curl -sS -m 20 -o /dev/null -w "%{http_code}" --connect-to example.com:443:registry.npmjs.org:443 https://example.com 2>/dev/null; true)" | |
| 575 | + echo "checkout=$(ls -A /workspace/repo | paste -sd,)" | |
| 576 | + echo "credential=$(cat /workspace/repo/.git/config 2>/dev/null || echo hidden)" | |
| 577 | + v=$(npm view left-pad version --no-update-notifier --loglevel=error 2>/tmp/npm.err) | |
| 578 | + echo "npm=$(printf "%s\n" "$v" | grep -E "^[0-9]+\.[0-9]+\.[0-9]+$" | tail -1)" | |
| 579 | + echo "npmerr=$(tail -3 /tmp/npm.err | paste -sd" " -)"' | |
| 580 | + out=$(run "mkdir -p ../jail-check ../jail-check.out && \"\$DIFFINERY_JAIL\" agent --dir ../jail-check --out ../jail-check.out -- sh -c $(printf '%q' "$inside")") | |
| 581 | + echo "$out" | jq -r '"exit \(.exitCode)", .stdout, .stderr' | |
| 582 | + o=$(jq -r .stdout <<<"$out") | |
| 583 | + fail=0 | |
| 584 | + jq -e '.exitCode == 0' >/dev/null <<<"$out" || { echo "::error::the jail didn't run (exit $(jq -r .exitCode <<<"$out"))"; fail=1; } | |
| 585 | + grep -qE '^direct=(ENETUNREACH|EHOSTUNREACH|ECONNREFUSED|EACCES)$' <<<"$o" || { echo "::error::the jail has a route out of its own"; fail=1; } | |
| 586 | + grep -qx 'other=403' <<<"$o" || { echo "::error::the proxy didn't refuse example.com"; fail=1; } | |
| 587 | + grep -qx 'checkout=.git' <<<"$o" || { echo "::error::the jail sees the person's checkout"; fail=1; } | |
| 588 | + grep -qx 'credential=hidden' <<<"$o" || { echo "::error::the jail sees the checkout's git config"; fail=1; } | |
| 589 | + grep -qE '^npm=[0-9]+\.[0-9]+' <<<"$o" || { echo "::error::npm view left-pad didn't work through the proxy"; fail=1; } | |
| 590 | + grep -qx 'sni=000' <<<"$o" || { echo "::error::the proxy let TLS for example.com through a tunnel to registry.npmjs.org"; fail=1; } | |
| 591 | + | |
| 592 | + # A headless run's jail: registries only, and Diffinery only at /-/model/ through the model proxy outside, | |
| 593 | + # which adds the run's token (a stand-in here, so Diffinery answers 401) and drops whatever the agent sends. | |
| 594 | + host=${DIFFINERY#https://} | |
| 595 | + headless='echo "model=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$ANTHROPIC_BASE_URL/v1/messages" -H "x-api-key: $ANTHROPIC_API_KEY" -H "content-type: application/json" -d "{}")" | |
| 596 | + echo "api=$(curl -s -o /dev/null -w "%{http_code}" --path-as-is "$ANTHROPIC_BASE_URL/../../api/v3/user")" | |
| 597 | + echo "connect=$(curl -sS -m 20 -o /dev/null -w "%{http_connect}" https://'"$host"'/api/v3/user 2>/dev/null || true)" | |
| 598 | + echo "vendor=$(curl -sS -m 20 -o /dev/null -w "%{http_connect}" https://api.anthropic.com 2>/dev/null || true)" | |
| 599 | + echo "token=$(env | grep -c dmt_ || true)" | |
| 600 | + echo "credential=$(cat /workspace/repo/.git/config 2>/dev/null || echo hidden)" | |
| 601 | + v=$(npm view left-pad version --no-update-notifier --loglevel=error 2>/dev/null) | |
| 602 | + echo "npm=$(printf "%s\n" "$v" | grep -E "^[0-9]+\.[0-9]+\.[0-9]+$" | tail -1)"' | |
| 603 | + out=$(run "mkdir -p ../headless-check ../headless-check.out && DIFFINERY_MODEL_TOKEN=dmt_livetest_standin DIFFINERY_MODEL_ORIGIN=$(printf '%q' "$DIFFINERY") ANTHROPIC_BASE_URL=http://127.0.0.1:3129/-/model/anthropic ANTHROPIC_API_KEY=diffinery-jail \"\$DIFFINERY_JAIL\" agent --dir ../headless-check --out ../headless-check.out --egress model -- sh -c $(printf '%q' "$headless")") | |
| 604 | + echo "$out" | jq -r '"exit \(.exitCode)", .stdout, .stderr' | |
| 605 | + o=$(jq -r .stdout <<<"$out") | |
| 606 | + jq -e '.exitCode == 0' >/dev/null <<<"$out" || { echo "::error::the headless jail didn't run (exit $(jq -r .exitCode <<<"$out"))"; fail=1; } | |
| 607 | + grep -qx 'model=401' <<<"$o" || { echo "::error::the model proxy didn't reach Diffinery's model endpoint with the run's token"; fail=1; } | |
| 608 | + grep -qx 'api=403' <<<"$o" || { echo "::error::the model proxy forwarded a path outside /-/model/"; fail=1; } | |
| 609 | + grep -qx 'connect=403' <<<"$o" || { echo "::error::the headless jail could open a tunnel to Diffinery"; fail=1; } | |
| 610 | + grep -qx 'vendor=403' <<<"$o" || { echo "::error::the headless jail could reach a CLI vendor"; fail=1; } | |
| 611 | + grep -qx 'token=0' <<<"$o" || { echo "::error::the run's token is inside the headless jail"; fail=1; } | |
| 612 | + grep -qx 'credential=hidden' <<<"$o" || { echo "::error::the headless jail sees the checkout's git config"; fail=1; } | |
| 613 | + grep -qE '^npm=[0-9]+\.[0-9]+' <<<"$o" || { echo "::error::npm view left-pad didn't work in the headless jail"; fail=1; } | |
| 614 | + exit $fail | |
| 615 | + | |
| 616 | + - name: Pull requests and the merge queue | |
| 617 | + if: steps.token.outputs.skip != 'true' | |
| 618 | + run: | | |
| 619 | + set -euo pipefail | |
| 620 | + Q="$REPO-pr" | |
| 621 | + api() { curl -fsS -X "$1" "$DIFFINERY/api/v3$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" ${3:+--data "$3"}; } | |
| 622 | + api POST /user/repos "{\"name\":\"$Q\",\"private\":true}" >/dev/null | |
| 623 | + echo "PR_REPO=$Q" >> "$GITHUB_ENV" | |
| 624 | + api PUT "/repos/$OWNER/$Q/actions/secrets/TOPSECRET" '{"value":"s3cret"}' >/dev/null | |
| 625 | + remote="https://x-token:${TOKEN}@${DIFFINERY#https://}/$OWNER/$Q.git" | |
| 626 | + mkdir -p pr/.github/workflows && cd pr | |
| 627 | + git init -q -b main | |
| 628 | + git config user.name "Live test" && git config user.email "live-test@diffinery.com" | |
| 629 | + printf 'a\nb\nc\n' > app.txt | |
| 630 | + cat > .github/workflows/ci.yml <<'EOF' | |
| 631 | + name: CI | |
| 632 | + on: | |
| 633 | + pull_request: | |
| 634 | + merge_group: | |
| 635 | + jobs: | |
| 636 | + test: | |
| 637 | + runs-on: ubuntu-latest | |
| 638 | + steps: | |
| 639 | + - uses: actions/checkout@v4 | |
| 640 | + - run: echo "pr ok event=${{ '${{' }} github.event_name }} ref=${{ '${{' }} github.ref }} sha=${{ '${{' }} github.sha }} head=$(git rev-parse HEAD) app=$(tr '\n' ' ' < app.txt)" | |
| 641 | + EOF | |
| 642 | + cat > .github/workflows/target.yml <<'EOF' | |
| 643 | + name: Target | |
| 644 | + on: pull_request_target | |
| 645 | + jobs: | |
| 646 | + t: | |
| 647 | + runs-on: ubuntu-latest | |
| 648 | + steps: | |
| 649 | + - run: echo "target ok secret=${{ '${{' }} secrets.TOPSECRET == 's3cret' }} ref=${{ '${{' }} github.ref }}" | |
| 650 | + EOF | |
| 651 | + git add -A && git commit -q -m "PR test" && git push -q "$remote" main | |
| 652 | + # The pull request changes app.txt and tries to rewrite the pull_request_target workflow. | |
| 653 | + git checkout -q -b change | |
| 654 | + printf 'A\nb\nc\n' > app.txt | |
| 655 | + sed -i 's/target ok/PWNED/' .github/workflows/target.yml | |
| 656 | + git commit -q -am "Change" && git push -q "$remote" change | |
| 657 | + head=$(git rev-parse HEAD) | |
| 658 | + # Main moves on, so the test merge commit holds both sides. | |
| 659 | + git checkout -q main | |
| 660 | + printf 'a\nb\nC\n' > app.txt | |
| 661 | + git commit -q -am "Main moves" && git push -q "$remote" main | |
| 662 | + n=$(api POST "/repos/$OWNER/$Q/pulls" '{"title":"Change","head":"change","base":"main"}' | jq -r .number) | |
| 663 | + echo "opened pull request #$n (head $head)" | |
| 664 | + | |
| 665 | + wait_run() { # event -> the completed run JSON | |
| 666 | + for i in $(seq 1 60); do | |
| 667 | + run=$(api GET "/repos/$OWNER/$Q/actions/runs?event=$1" | jq -c '.workflow_runs[0] // empty') | |
| 668 | + [ -n "$run" ] && [ "$(jq -r .status <<<"$run")" = completed ] && { echo "$run"; return; } | |
| 669 | + sleep 10 | |
| 670 | + done | |
| 671 | + echo "::error::no completed $1 run" >&2; exit 1 | |
| 672 | + } | |
| 673 | + log_of() { for job in $(api GET "/repos/$OWNER/$Q/actions/runs/$1/jobs" | jq -r '.jobs[].id'); do api GET "/repos/$OWNER/$Q/actions/jobs/$job/logs"; done; } | |
| 674 | + pr=$(wait_run pull_request) | |
| 675 | + echo "$pr" | jq '{event, ref, sha, head_sha, conclusion}' | |
| 676 | + [ "$(jq -r .conclusion <<<"$pr")" = success ] || { echo "::error::the pull_request run failed"; exit 1; } | |
| 677 | + [ "$(jq -r .ref <<<"$pr")" = "refs/pull/$n/merge" ] && [ "$(jq -r .head_sha <<<"$pr")" = "$head" ] && [ "$(jq -r .sha <<<"$pr")" != "$head" ] || { echo "::error::the pull_request run did not use the test merge commit"; exit 1; } | |
| 678 | + log_of "$(jq -r .id <<<"$pr")" | grep -F "pr ok event=pull_request ref=refs/pull/$n/merge" | grep -F "app=A b C" || { echo "::error::the pull_request job did not check out the merged code"; exit 1; } | |
| 679 | + target=$(wait_run pull_request_target) | |
| 680 | + echo "$target" | jq '{event, ref, sha, head_sha, conclusion}' | |
| 681 | + tlog=$(log_of "$(jq -r .id <<<"$target")") | |
| 682 | + grep -F "target ok secret=true ref=refs/heads/main" <<<"$tlog" || { echo "::error::pull_request_target did not run the base workflow with secrets"; exit 1; } | |
| 683 | + ! grep -q PWNED <<<"$tlog" || { echo "::error::pull_request_target ran the pull request's workflow"; exit 1; } | |
| 684 | + | |
| 685 | + api PUT "/repos/$OWNER/$Q/branches/main/protection" '{"requirePullRequest":true,"requiredChecks":["CI / test"],"mergeQueue":{"method":"squash","checkTimeoutMinutes":20}}' >/dev/null | |
| 686 | + code=$(curl -sS -o /dev/null -w '%{http_code}' -X PUT "$DIFFINERY/api/v3/repos/$OWNER/$Q/pulls/$n/merge" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data '{}') | |
| 687 | + [ "$code" = 405 ] || { echo "::error::a direct merge into a merge queue branch returned $code"; exit 1; } | |
| 688 | + api PUT "/repos/$OWNER/$Q/pulls/$n/merge-queue" '{"delete_branch":true}' | jq '{state, position: .entry.position, merge_group: .entry.merge_group}' | |
| 689 | + for i in $(seq 1 60); do | |
| 690 | + merged=$(api GET "/repos/$OWNER/$Q/pulls/$n" | jq -r .merged) | |
| 691 | + [ "$merged" = true ] && break | |
| 692 | + sleep 10 | |
| 693 | + done | |
| 694 | + [ "$merged" = true ] || { echo "::error::the merge queue did not merge #$n"; api GET "/repos/$OWNER/$Q/merge-queue/main" | jq .; exit 1; } | |
| 695 | + group=$(wait_run merge_group) | |
| 696 | + echo "$group" | jq '{event, ref, sha, conclusion}' | |
| 697 | + [[ "$(jq -r .ref <<<"$group")" == refs/heads/gh-readonly-queue/main/pr-$n-* ]] || { echo "::error::the merge_group run is not on a queue branch"; exit 1; } | |
| 698 | + sha=$(api GET "/repos/$OWNER/$Q/branches/main" | jq -r .commit.sha) | |
| 699 | + [ "$sha" = "$(jq -r .sha <<<"$group")" ] || { echo "::error::main ($sha) is not the tested merge group"; exit 1; } | |
| 700 | + git fetch -q "$remote" main && [ "$(git show FETCH_HEAD:app.txt | tr '\n' ' ')" = "A b C " ] || { echo "::error::main does not hold the merged change"; exit 1; } | |
| 701 | + echo "pull request #$n merged through the queue as $sha" | |
| 702 | + | |
| 703 | + - name: Import a public GitHub repository | |
| 704 | + if: steps.token.outputs.skip != 'true' | |
| 705 | + env: | |
| 706 | + # A small public repository with issues and pull requests; a repository variable can change it. | |
| 707 | + SOURCE: ${{ vars.LIVE_IMPORT_SOURCE || 'octocat/git-consortium' }} | |
| 708 | + # Token-free: Diffinery keeps no GitHub credentials of its own (the importer takes a person's token when they import). | |
| 709 | + GITHUB_TOKEN_FOR_IMPORT: "" | |
| 710 | + run: | | |
| 711 | + set -euo pipefail | |
| 712 | + api() { curl -fsS -X "$1" "$DIFFINERY/api/v3$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" ${3:+--data "$3"}; } | |
| 713 | + name="$REPO-import" | |
| 714 | + body=$(jq -nc --arg s "$SOURCE" --arg o "$OWNER" --arg n "$name" --arg t "${GITHUB_TOKEN_FOR_IMPORT:-}" \ | |
| 715 | + '{source: $s, owner: $o, name: $n, visibility: "private"} + (if $t == "" then {} else {github_token: $t} end)') | |
| 716 | + # Token-free, GitHub allows 60 requests an hour per address: a rate limit skips this check with a warning. | |
| 717 | + # Starting the import reads the repository from GitHub; a GitHub outage (502 "could not reach GitHub") | |
| 718 | + # is retried, and skips this check with a warning if it outlasts the retries. While other imports hold | |
| 719 | + # every slot (an import campaign), it waits up to 15 minutes for one, so a busy account doesn't hide this check. | |
| 720 | + tries=0 waits=0 | |
| 721 | + while :; do | |
| 722 | + code=$(curl -sS -o import.json -w '%{http_code}' -X POST "$DIFFINERY/api/v3/diffinery/imports" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" \ | |
| 723 | + --data-binary @- <<<"$body" || true) | |
| 724 | + unreachable=$(jq -r '.message // "" | startswith("could not reach GitHub")' import.json 2>/dev/null || echo false) | |
| 725 | + busy=$(jq -r '.message // "" | startswith("too many repository imports are already running")' import.json 2>/dev/null || echo false) | |
| 726 | + if [ "$code" = 429 ] && [ "$busy" = true ]; then | |
| 727 | + waits=$((waits + 1)) | |
| 728 | + [ "$waits" -le 30 ] || break | |
| 729 | + [ "$waits" -gt 1 ] || echo "every import slot is taken ($(jq -r .message import.json)); waiting for one, up to 15 minutes" | |
| 730 | + sleep 30 | |
| 731 | + continue | |
| 732 | + fi | |
| 733 | + { [ "$code" = 502 ] && [ "$unreachable" = true ]; } || [ "$code" = 000 ] || break | |
| 734 | + tries=$((tries + 1)) | |
| 735 | + [ "$tries" -lt 4 ] || break | |
| 736 | + echo "starting the import returned $code ($(jq -r .message import.json 2>/dev/null)); trying again in 30 seconds" | |
| 737 | + sleep 30 | |
| 738 | + done | |
| 739 | + if [ "$code" = 429 ] && [ "$busy" = true ]; then echo "::warning::no import slot came free in 15 minutes, so the import check is skipped: $(jq -r .message import.json)"; exit 0; fi | |
| 740 | + if [ "$code" = 429 ]; then echo "::warning::GitHub's rate limit stopped the import check: $(jq -r .message import.json)"; exit 0; fi | |
| 741 | + if [ "$code" = 502 ] && [ "$unreachable" = true ]; then echo "::warning::GitHub could not be reached, so the import check is skipped: $(jq -r .message import.json)"; exit 0; fi | |
| 742 | + [ "$code" = 201 ] || { echo "::error::starting the import returned $code: $(cat import.json)"; exit 1; } | |
| 743 | + echo "IMPORT_REPO=$name" >> "$GITHUB_ENV" | |
| 744 | + id=$(jq -r .id import.json) | |
| 745 | + echo "import $id: $SOURCE -> $OWNER/$name" | |
| 746 | + for i in $(seq 1 90); do | |
| 747 | + imp=$(api GET "/diffinery/imports/$id") | |
| 748 | + status=$(jq -r .status <<<"$imp") | |
| 749 | + case "$status" in succeeded|failed|cancelled) break ;; esac | |
| 750 | + if jq -e '[.steps[].message // ""] | any(test("rate limit"))' >/dev/null <<<"$imp"; then | |
| 751 | + api POST "/diffinery/imports/$id/cancel" >/dev/null || true | |
| 752 | + echo "::warning::GitHub's rate limit paused the import, so the import check is skipped: $(jq -r '[.steps[].message // ""] | map(select(test("rate limit")))[0]' <<<"$imp")" | |
| 753 | + exit 0 | |
| 754 | + fi | |
| 755 | + sleep 10 | |
| 756 | + done | |
| 757 | + jq '{status, error, warnings, steps: [.steps[] | "\(.name): \(.status) \(.message // "")"], git}' <<<"$imp" | |
| 758 | + [ "$status" = succeeded ] || { echo "::error::the import ended $status"; exit 1; } | |
| 759 | + | |
| 760 | + # The same refs, issue and pull request numbers as on GitHub (when GitHub still answers this runner). | |
| 761 | + R="/repos/$OWNER/$name" | |
| 762 | + [ "$(api GET "$R" | jq -r .private)" = true ] || { echo "::error::the imported repository is not private"; exit 1; } | |
| 763 | + gh() { curl -fsS "https://api.github.com/repos/$SOURCE$1" -H "Accept: application/vnd.github+json" ${GITHUB_TOKEN_FOR_IMPORT:+-H "Authorization: Bearer $GITHUB_TOKEN_FOR_IMPORT"}; } | |
| 764 | + if src=$(gh "") && branches=$(gh "/branches?per_page=100") && items=$(gh "/issues?state=all&per_page=30&sort=created&direction=asc"); then | |
| 765 | + def=$(jq -r .default_branch <<<"$src") | |
| 766 | + want=$(jq -r --arg b "$def" '.[] | select(.name == $b) | .commit.sha' <<<"$branches") | |
| 767 | + got=$(api GET "$R/branches/$def" | jq -r .commit.sha) | |
| 768 | + [ "$got" = "$want" ] || { echo "::error::$def is $got on Diffinery, $want on GitHub"; exit 1; } | |
| 769 | + diff <(jq -r '.[].name' <<<"$branches" | sort) <(api GET "$R/branches?per_page=100" | jq -r '.[].name' | sort) || { echo "::error::the branches differ"; exit 1; } | |
| 770 | + n=0 | |
| 771 | + for row in $(jq -r '.[] | "\(.number):\(if .pull_request then "pulls" else "issues" end)"' <<<"$items"); do | |
| 772 | + num=${row%%:*} kind=${row#*:} | |
| 773 | + want=$(jq -r --argjson n "$num" '.[] | select(.number == $n) | .title' <<<"$items") | |
| 774 | + got=$(api GET "$R/$kind/$num" | jq -r .title) | |
| 775 | + [ "$got" = "$want" ] || { echo "::error::$kind #$num is \"$got\" on Diffinery, \"$want\" on GitHub"; exit 1; } | |
| 776 | + n=$((n + 1)) | |
| 777 | + done | |
| 778 | + echo "$def at $got, $(jq length <<<"$branches") branches and $n issues and pull requests match GitHub" | |
| 779 | + else | |
| 780 | + echo "::warning::GitHub did not answer this runner (rate limit?), so the import is checked without comparing to GitHub" | |
| 781 | + fi | |
| 782 | + # No side effects: importing runs no workflows and sends no notifications. | |
| 783 | + runs=$(api GET "$R/actions/runs" | jq .total_count) | |
| 784 | + [ "$runs" = 0 ] || { echo "::error::the import started $runs workflow runs"; exit 1; } | |
| 785 | + | |
| 786 | + - name: Scheduled workflow | |
| 787 | + # Runs even when the import check failed: the two are independent. It needs the scheduled | |
| 788 | + # workflow pushed, though, so a failed setup doesn't add a 13-minute wait for a run that can't come. | |
| 789 | + if: ${{ !cancelled() && steps.token.outputs.skip != 'true' && steps.push.outcome == 'success' }} | |
| 790 | + run: | | |
| 791 | + set -euo pipefail | |
| 792 | + # The cron trigger fires every 5 minutes, so allow two windows. | |
| 793 | + for i in $(seq 1 75); do | |
| 794 | + run=$(curl -fsS "$DIFFINERY/api/v3/repos/$OWNER/$REPO/actions/runs?event=schedule" -H "Authorization: Bearer $TOKEN" | jq -c '.workflow_runs[0] // empty') | |
| 795 | + [ -n "$run" ] && [ "$(jq -r .status <<<"$run")" = completed ] && break | |
| 796 | + sleep 10 | |
| 797 | + done | |
| 798 | + if [ -z "$run" ]; then | |
| 799 | + # Whether the scheduler stopped ticking (last_tick_at is old) or ticked without starting the run | |
| 800 | + # (site administrators only: another token prints the 403). | |
| 801 | + echo "scheduler: $(curl -sS "$DIFFINERY/api/v3/diffinery/admin/scheduler" -H "Authorization: Bearer $TOKEN") (now $(date -u +%FT%TZ))" | |
| 802 | + echo "::error::no scheduled run started" | |
| 803 | + exit 1 | |
| 804 | + fi | |
| 805 | + echo "$run" | jq '{id, event, status, conclusion}' | |
| 806 | + [ "$(jq -r .conclusion <<<"$run")" = success ] || { echo "::error::the scheduled run did not succeed"; exit 1; } | |
| 807 | + | |
| 808 | + - name: Delete the throwaway repositories | |
| 809 | + if: always() && steps.token.outputs.skip != 'true' | |
| 810 | + run: | | |
| 811 | + curl -sS -o /dev/null -w 'package %{http_code}\n' -X DELETE "$DIFFINERY/api/v3/users/$OWNER/packages/container/${REPO,,}" -H "Authorization: Bearer $TOKEN" | |
| 812 | + curl -sS -o /dev/null -w '%{http_code}\n' -X DELETE "$DIFFINERY/api/v3/repos/$OWNER/$REPO" -H "Authorization: Bearer $TOKEN" | |
| 813 | + [ -z "${PR_REPO:-}" ] || curl -sS -o /dev/null -w '%{http_code}\n' -X DELETE "$DIFFINERY/api/v3/repos/$OWNER/$PR_REPO" -H "Authorization: Bearer $TOKEN" | |
| 814 | + [ -z "${IMPORT_REPO:-}" ] || curl -sS -o /dev/null -w '%{http_code}\n' -X DELETE "$DIFFINERY/api/v3/repos/$OWNER/$IMPORT_REPO" -H "Authorization: Bearer $TOKEN" |
added.github/workflows/mirror-to-github.yml+51−0View file
| @@ -1,0 +1,51 @@ | ||
| 1 | +name: Mirror to GitHub | |
| 2 | + | |
| 3 | +# Keeps the public copy at github.com/codician-team/diffinery in step with main, | |
| 4 | +# through the deterministic rewrite in scripts/mirror-to-github.sh. Runs on Diffinery | |
| 5 | +# only. Uses MIRROR_TOKEN (a fine-grained GitHub token with Contents and | |
| 6 | +# Workflows write on that one repository), a secret of the github-mirror environment. | |
| 7 | +# That environment is limited to protected branches, so the token also reaches runs | |
| 8 | +# after an agent's merge. Without the token the job says so and does nothing. | |
| 9 | +on: | |
| 10 | + push: | |
| 11 | + branches: [main] | |
| 12 | + schedule: | |
| 13 | + - cron: "23 */6 * * *" | |
| 14 | + workflow_dispatch: | |
| 15 | + | |
| 16 | +permissions: | |
| 17 | + contents: read | |
| 18 | + | |
| 19 | +concurrency: | |
| 20 | + group: mirror-to-github | |
| 21 | + cancel-in-progress: false | |
| 22 | + | |
| 23 | +jobs: | |
| 24 | + mirror: | |
| 25 | + name: Push main to the GitHub mirror | |
| 26 | + if: ${{ github.server_url != 'https://github.com' && github.repository == 'codician/diffinery' }} | |
| 27 | + runs-on: ubuntu-latest | |
| 28 | + environment: github-mirror | |
| 29 | + timeout-minutes: 10 | |
| 30 | + env: | |
| 31 | + MIRROR_TOKEN: ${{ secrets.MIRROR_TOKEN }} | |
| 32 | + steps: | |
| 33 | + - name: Check the GitHub token | |
| 34 | + id: token | |
| 35 | + run: | | |
| 36 | + if [ -z "$MIRROR_TOKEN" ]; then | |
| 37 | + echo "::notice::MIRROR_TOKEN is not set, so GitHub is not updated." | |
| 38 | + echo "skip=true" >> "$GITHUB_OUTPUT" | |
| 39 | + fi | |
| 40 | + - uses: actions/checkout@v4 | |
| 41 | + if: steps.token.outputs.skip != 'true' | |
| 42 | + with: | |
| 43 | + ref: main | |
| 44 | + fetch-depth: 0 | |
| 45 | + - if: steps.token.outputs.skip != 'true' | |
| 46 | + # Pinned: another filter-repo version could write different commits. | |
| 47 | + run: python3 -m pip install --quiet --break-system-packages git-filter-repo==2.47.0 || python3 -m pip install --quiet git-filter-repo==2.47.0 | |
| 48 | + - if: steps.token.outputs.skip != 'true' | |
| 49 | + run: scripts/mirror-to-github.sh | |
| 50 | + env: | |
| 51 | + MIRROR_SRC: ${{ github.workspace }} |
added.gitignore+9−0View file
| @@ -1,0 +1,9 @@ | ||
| 1 | +node_modules | |
| 2 | +dist/ | |
| 3 | +.wrangler/ | |
| 4 | +.turbo/ | |
| 5 | +.dev.vars | |
| 6 | +*.log | |
| 7 | +coverage/ | |
| 8 | +.DS_Store | |
| 9 | +wrangler.deploy.jsonc |
addedAGENTS.md+78−0View file
| @@ -1,0 +1,78 @@ | ||
| 1 | +# AGENTS.md | |
| 2 | + | |
| 3 | +Guidance for AI coding agents (Claude Code, Codex, Cursor and others) working on this | |
| 4 | +repository. People should read [CONTRIBUTING.md](CONTRIBUTING.md); everything here agrees | |
| 5 | +with it. | |
| 6 | + | |
| 7 | +## What this repository is | |
| 8 | + | |
| 9 | +Diffinery: an open-source git forge with GitHub Actions–compatible CI that runs entirely on | |
| 10 | +Cloudflare. One Cloudflare Worker serves the web UI, git over HTTPS, a GitHub-compatible | |
| 11 | +REST and GraphQL API and an MCP server; each repository is a Durable Object with its own | |
| 12 | +SQLite database; CI jobs run in Cloudflare Containers. Development happens at | |
| 13 | +https://diffinery.com/codician/diffinery; https://github.com/codician-team/diffinery is a | |
| 14 | +mirror. | |
| 15 | + | |
| 16 | +## Layout | |
| 17 | + | |
| 18 | +``` | |
| 19 | +apps/diffinery the Worker: src/ (web UI, git, REST/GraphQL, MCP, Actions), migrations/ (D1), | |
| 20 | + docs/ (served at diffinery.com/docs), site/ (marketing pages), test/ (unit and e2e) | |
| 21 | +apps/cli the `diffinery` command-line client | |
| 22 | +apps/vscode the VS Code, Cursor and Windsurf extension | |
| 23 | +apps/jetbrains the JetBrains IDE plugin | |
| 24 | +plugins the Claude Code and Codex plugins | |
| 25 | +containers/runner the runner agent, sandbox server and container image | |
| 26 | +packages/git-core git objects, packfiles, deltas, diff and merge, in pure TypeScript | |
| 27 | +packages/workflow-yaml the GitHub Actions workflow parser, expressions, matrices and cron | |
| 28 | +packages/shared types shared by the Worker and the runner | |
| 29 | +scripts setup and deploy scripts for Cloudflare | |
| 30 | +``` | |
| 31 | + | |
| 32 | +## Commands | |
| 33 | + | |
| 34 | +Node.js 22 and pnpm 10 (`corepack enable`). No Cloudflare account is needed to develop. | |
| 35 | + | |
| 36 | +```sh | |
| 37 | +pnpm install | |
| 38 | +pnpm -r build # runner agent, web islands and docs | |
| 39 | +pnpm -r typecheck | |
| 40 | +pnpm -r test # unit tests (Vitest) | |
| 41 | +pnpm --filter @diffinery/app db:migrate:local # local D1 | |
| 42 | +pnpm dev # http://localhost:8787, signed in as dev@diffinery.local | |
| 43 | +cd apps/diffinery && node --test --test-timeout=600000 test/e2e/<file>.test.mjs # one e2e file | |
| 44 | +``` | |
| 45 | + | |
| 46 | +Run repository tools with `pnpm exec`, never bare `npx`. Run the typecheck and the unit | |
| 47 | +tests before you ask for review, and the end-to-end file closest to your change. Tests must | |
| 48 | +not depend on the speed of the machine they run on. | |
| 49 | + | |
| 50 | +## Rules | |
| 51 | + | |
| 52 | +- **Docs ship with the feature.** Any user- or admin-visible change updates the Markdown in | |
| 53 | + `apps/diffinery/docs` (and `apps/diffinery/site` for product pages). Write for people and | |
| 54 | + agents: short sentences, exact names of buttons, settings, endpoints and flags. | |
| 55 | +- **Follow GitHub where GitHub has an API, event or workflow feature**, so git, `gh`, | |
| 56 | + Octokit and actions keep working. Document any difference in | |
| 57 | + `apps/diffinery/docs/actions/compatibility.md` or the relevant page. | |
| 58 | +- **D1 migrations** live in `apps/diffinery/migrations`. Use the next free number and never | |
| 59 | + edit a migration that has shipped. | |
| 60 | +- **Security:** guardrails for agents are enforced on the server for git, REST, MCP and the | |
| 61 | + web UI alike. Never add a bypass, never log secrets or tokens, and never weaken push | |
| 62 | + protection, branch protection or the runner sandbox to make something pass. | |
| 63 | +- **UI:** passkeys first, no passwords; Kumo components through `KSelect`, `KCheckbox` and | |
| 64 | + `KRadio`, never a native `<select>`, checkbox or radio. See | |
| 65 | + [docs/PREFERENCES.md](docs/PREFERENCES.md). | |
| 66 | +- **Code style:** strict TypeScript, few dependencies, comments that say why, matching the | |
| 67 | + density and style of the code around your change. | |
| 68 | +- **Pull requests:** one change each, a title that says what it does, an `intent` that says | |
| 69 | + what it is for, and the person you work for in the description. Changes under | |
| 70 | + `.github/workflows` need a maintainer to merge them. Never skip or disable a test to get | |
| 71 | + green. | |
| 72 | + | |
| 73 | +## Reading the docs as an agent | |
| 74 | + | |
| 75 | +Every docs page is Markdown at `https://diffinery.com/docs/<page>.md`, the whole set is in | |
| 76 | +https://diffinery.com/llms.txt and https://diffinery.com/llms-full.txt, and the MCP server | |
| 77 | +at `https://diffinery.com/mcp` has `search_docs` and `read_docs`. See | |
| 78 | +[Docs for AI agents](https://diffinery.com/docs/agents/docs-for-agents). |
addedCHANGELOG.md+55−0View file
| @@ -1,0 +1,55 @@ | ||
| 1 | +# Changelog | |
| 2 | + | |
| 3 | +Notable changes to Diffinery. Diffinery is deployed continuously from `main`; versions mark | |
| 4 | +points worth naming. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). | |
| 5 | + | |
| 6 | +## [0.1.0] - 2026-10-25 | |
| 7 | + | |
| 8 | +The first open-source release, under the Apache License 2.0. | |
| 9 | + | |
| 10 | +### Git hosting and collaboration | |
| 11 | + | |
| 12 | +- Git over HTTPS with tokens or browser sign-in, shallow clones, Git LFS and | |
| 13 | + `refs/pull/<n>/head` and `/merge`; each repository is a Durable Object with its own SQLite | |
| 14 | + database, and large pushes are unpacked from R2 in pieces. | |
| 15 | +- Pull requests with an `intent` field, drafts, reviews with line comments, required | |
| 16 | + approvals and checks, merge, squash and rebase, auto-merge, merge on green and a merge | |
| 17 | + queue with `merge_group` runs. | |
| 18 | +- Issues, labels, milestones, reactions and mentions; releases with assets and generated | |
| 19 | + notes; checks and commit statuses. | |
| 20 | +- Branch protection, push protection that blocks leaked credentials on every push, an | |
| 21 | + append-only audit log, moderation, quotas and kill switches. | |
| 22 | +- A container registry for OCI images. | |
| 23 | + | |
| 24 | +### Actions (CI/CD) | |
| 25 | + | |
| 26 | +- Runs GitHub Actions workflows from `.github/workflows/` (and `.diffinery/workflows/`): | |
| 27 | + triggers, expressions, matrices, `needs`, outputs, reusable workflows, environments, | |
| 28 | + secrets, variables, OIDC, service containers, Docker, concurrency groups and schedules. | |
| 29 | +- JavaScript, composite and Docker actions from GitHub, with `checkout`, `setup-node`, | |
| 30 | + `setup-python`, `setup-go`, `cache` and the artifact actions built into the runner. | |
| 31 | +- Hosted runners in Cloudflare Containers: a fresh Ubuntu 24.04 container per job, 4 vCPU and | |
| 32 | + 12 GiB by default, smaller sizes chosen per job from its own measured runs, jobs starting in | |
| 33 | + about 3 to 5 seconds. Self-hosted runners with `diffinery runner`. | |
| 34 | + | |
| 35 | +### AI agents | |
| 36 | + | |
| 37 | +- Agent accounts owned by a person or an organization, with server-enforced guardrails: no | |
| 38 | + pushes or merges to protected branches unless a rule allows it, a person's approval on | |
| 39 | + agent pull requests, and no secrets for runs of agent-written code. | |
| 40 | +- A built-in MCP server at `/mcp` with browser sign-in, sandboxes, agent tasks that run | |
| 41 | + Claude Code, Codex or your own command and open a pull request, agent code review, | |
| 42 | + projects with shared memory and a Today page of what needs you. | |
| 43 | +- Plugins for Claude Code and Codex, the `diffinery` CLI, `gh` support, and extensions for | |
| 44 | + VS Code, Cursor, Windsurf and JetBrains IDEs. | |
| 45 | + | |
| 46 | +### Moving and running | |
| 47 | + | |
| 48 | +- A GitHub importer for a repository or a whole organization, with history, issues, pull | |
| 49 | + requests, reviews and releases. | |
| 50 | +- GitHub-compatible REST (`/api/v3`) and GraphQL (`/api/graphql`) APIs and webhooks. | |
| 51 | +- `scripts/setup.sh`, which deploys an instance to your own Cloudflare account and domain. | |
| 52 | +- Documentation at https://diffinery.com/docs, also as Markdown, `llms.txt` and | |
| 53 | + `llms-full.txt`. | |
| 54 | + | |
| 55 | +[0.1.0]: https://diffinery.com/codician/diffinery/releases/tag/v0.1.0 |
addedCITATION.cff+38−0View file
| @@ -1,0 +1,38 @@ | ||
| 1 | +cff-version: 1.2.0 | |
| 2 | +message: "If you use or refer to Diffinery, please cite it as below." | |
| 3 | +type: software | |
| 4 | +title: "Diffinery" | |
| 5 | +abstract: >- | |
| 6 | + Diffinery is an open-source git forge with GitHub Actions-compatible CI that runs | |
| 7 | + entirely on Cloudflare (Workers, Durable Objects, D1, R2, Queues, Workflows and | |
| 8 | + Containers). It hosts Git repositories, pull requests with reviews and a merge queue, | |
| 9 | + issues, releases and a container registry, runs existing .github/workflows files on | |
| 10 | + per-job containers, and gives AI coding agents accounts of their own with | |
| 11 | + server-enforced guardrails, agent tasks and agent code review that run Claude Code | |
| 12 | + or Codex in sandboxes, a built-in MCP server and plugins for Claude Code and Codex. | |
| 13 | +authors: | |
| 14 | + - name: "Codician Team" | |
| 15 | +version: "0.1.0" | |
| 16 | +date-released: "2026-10-25" | |
| 17 | +license: Apache-2.0 | |
| 18 | +url: "https://diffinery.com" | |
| 19 | +repository-code: "https://github.com/codician-team/diffinery" | |
| 20 | +repository: "https://diffinery.com/codician/diffinery" | |
| 21 | +keywords: | |
| 22 | + - git | |
| 23 | + - git-forge | |
| 24 | + - github-alternative | |
| 25 | + - github-actions | |
| 26 | + - continuous-integration | |
| 27 | + - ci-cd | |
| 28 | + - cloudflare-workers | |
| 29 | + - durable-objects | |
| 30 | + - cloudflare-containers | |
| 31 | + - self-hosted | |
| 32 | + - serverless | |
| 33 | + - code-review | |
| 34 | + - ai-agents | |
| 35 | + - ai-code-review | |
| 36 | + - claude-code | |
| 37 | + - codex | |
| 38 | + - mcp |
addedCLAUDE.md+1−0View file
| @@ -1,0 +1,1 @@ | ||
| 1 | +@AGENTS.md |
addedCODE_OF_CONDUCT.md+83−0View file
| @@ -1,0 +1,83 @@ | ||
| 1 | +# Contributor Covenant Code of Conduct | |
| 2 | + | |
| 3 | +## Our Pledge | |
| 4 | + | |
| 5 | +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. | |
| 6 | + | |
| 7 | +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. | |
| 8 | + | |
| 9 | +## Our Standards | |
| 10 | + | |
| 11 | +Examples of behavior that contributes to a positive environment for our community include: | |
| 12 | + | |
| 13 | +* Demonstrating empathy and kindness toward other people | |
| 14 | +* Being respectful of differing opinions, viewpoints, and experiences | |
| 15 | +* Giving and gracefully accepting constructive feedback | |
| 16 | +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience | |
| 17 | +* Focusing on what is best not just for us as individuals, but for the overall community | |
| 18 | + | |
| 19 | +Examples of unacceptable behavior include: | |
| 20 | + | |
| 21 | +* The use of sexualized language or imagery, and sexual attention or advances of any kind | |
| 22 | +* Trolling, insulting or derogatory comments, and personal or political attacks | |
| 23 | +* Public or private harassment | |
| 24 | +* Publishing others' private information, such as a physical or email address, without their explicit permission | |
| 25 | +* Other conduct which could reasonably be considered inappropriate in a professional setting | |
| 26 | + | |
| 27 | +## Enforcement Responsibilities | |
| 28 | + | |
| 29 | +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. | |
| 30 | + | |
| 31 | +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. | |
| 32 | + | |
| 33 | +## Scope | |
| 34 | + | |
| 35 | +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. | |
| 36 | + | |
| 37 | +## Enforcement | |
| 38 | + | |
| 39 | +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at the private form at [diffinery.com/security/report](https://diffinery.com/security/report) (start the summary with "Code of conduct"; only the maintainers can read it), or with the **Report** action on the comment, issue, pull request or account on diffinery.com. All complaints will be reviewed and investigated promptly and fairly. | |
| 40 | + | |
| 41 | +All community leaders are obligated to respect the privacy and security of the reporter of any incident. | |
| 42 | + | |
| 43 | +## Enforcement Guidelines | |
| 44 | + | |
| 45 | +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: | |
| 46 | + | |
| 47 | +### 1. Correction | |
| 48 | + | |
| 49 | +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. | |
| 50 | + | |
| 51 | +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. | |
| 52 | + | |
| 53 | +### 2. Warning | |
| 54 | + | |
| 55 | +**Community Impact**: A violation through a single incident or series of actions. | |
| 56 | + | |
| 57 | +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. | |
| 58 | + | |
| 59 | +### 3. Temporary Ban | |
| 60 | + | |
| 61 | +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. | |
| 62 | + | |
| 63 | +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. | |
| 64 | + | |
| 65 | +### 4. Permanent Ban | |
| 66 | + | |
| 67 | +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. | |
| 68 | + | |
| 69 | +**Consequence**: A permanent ban from any sort of public interaction within the community. | |
| 70 | + | |
| 71 | +## Attribution | |
| 72 | + | |
| 73 | +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. | |
| 74 | + | |
| 75 | +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. | |
| 76 | + | |
| 77 | +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. | |
| 78 | + | |
| 79 | +[homepage]: https://www.contributor-covenant.org | |
| 80 | +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html | |
| 81 | +[Mozilla CoC]: https://github.com/mozilla/diversity | |
| 82 | +[FAQ]: https://www.contributor-covenant.org/faq | |
| 83 | +[translations]: https://www.contributor-covenant.org/translations |
addedCONTRIBUTING.md+107−0View file
| @@ -1,0 +1,107 @@ | ||
| 1 | +# Contributing to Diffinery | |
| 2 | + | |
| 3 | +Thanks for your interest in Diffinery. Bug reports, fixes, documentation, compatibility | |
| 4 | +reports and new features are all welcome, from people and from AI agents working for | |
| 5 | +them. | |
| 6 | + | |
| 7 | +## Where development happens | |
| 8 | + | |
| 9 | +Diffinery is developed on Diffinery itself, at | |
| 10 | +[diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery). Its CI, | |
| 11 | +pull requests, code review and deploys all run there. | |
| 12 | +[github.com/codician-team/diffinery](https://github.com/codician-team/diffinery) is a | |
| 13 | +mirror that follows `main`. | |
| 14 | + | |
| 15 | +- **If you have an account on diffinery.com**, open issues and pull requests there. | |
| 16 | +- **If you don't**, open them on the GitHub mirror. Accounts on diffinery.com are by | |
| 17 | + invitation for now, so this is the usual path for new contributors. A maintainer lands | |
| 18 | + accepted pull requests on Diffinery and they come back to GitHub with the next sync; | |
| 19 | + the GitHub pull request is then closed with a link to the change. The mirror rewrites | |
| 20 | + commit authorship, so look for the change by its title or pull request number rather | |
| 21 | + than by commit SHA. | |
| 22 | + | |
| 23 | +Security problems are the exception: report them privately, never in an issue or pull | |
| 24 | +request. See [SECURITY.md](SECURITY.md). | |
| 25 | + | |
| 26 | +## Before you start | |
| 27 | + | |
| 28 | +- For anything bigger than a small fix, open an issue first and describe what you want to | |
| 29 | + change, so we can agree on the approach before you spend time on it. | |
| 30 | +- Check [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility) | |
| 31 | + before reporting a workflow difference: known gaps are listed there. | |
| 32 | +- Please follow the [Code of Conduct](CODE_OF_CONDUCT.md). | |
| 33 | + | |
| 34 | +## Set up a development environment | |
| 35 | + | |
| 36 | +You need Node.js 22 and pnpm 10 (the repository pins `pnpm@10.28.0`; `corepack enable` | |
| 37 | +picks it up). Docker is only needed to build the runner image. No Cloudflare account is | |
| 38 | +needed: everything runs locally under `wrangler dev`. | |
| 39 | + | |
| 40 | +```sh | |
| 41 | +pnpm install | |
| 42 | +pnpm -r build # runner agent, web islands and docs | |
| 43 | +pnpm --filter @diffinery/app db:migrate:local # local D1 | |
| 44 | +pnpm dev # http://localhost:8787, signed in as dev@diffinery.local | |
| 45 | +``` | |
| 46 | + | |
| 47 | +## Tests | |
| 48 | + | |
| 49 | +```sh | |
| 50 | +pnpm -r typecheck | |
| 51 | +pnpm -r test # unit tests (Vitest) | |
| 52 | +pnpm --filter @diffinery/app e2e # end-to-end: wrangler dev, real git, the runner agent and Chromium | |
| 53 | +``` | |
| 54 | + | |
| 55 | +The end-to-end suite pushes real repositories with `git`, runs their workflows through the | |
| 56 | +real runner agent, drives the MCP server and the REST API with `@octokit/rest`, and signs | |
| 57 | +in with passkeys in Chromium with a virtual authenticator. It takes a while and uses a lot | |
| 58 | +of memory for the large-push tests; run the file you changed first, for example | |
| 59 | +`cd apps/diffinery && node --test --test-timeout=600000 test/e2e/registry.test.mjs`. | |
| 60 | + | |
| 61 | +Pull requests must pass CI (typecheck, the Worker bundle and the unit tests) and the | |
| 62 | +end-to-end shards before they merge. Tests must not depend on the speed of the machine | |
| 63 | +they run on. | |
| 64 | + | |
| 65 | +## Conventions | |
| 66 | + | |
| 67 | +- **TypeScript everywhere**, in strict mode, sharing code through the workspace packages in | |
| 68 | + `packages/`. Keep dependencies few: the git core (`packages/git-core`) needs only `pako` and `diff`. | |
| 69 | +- **Comments say why**, in plain sentences, next to the code they explain. Match the | |
| 70 | + density and style of the code around your change. | |
| 71 | +- **Docs ship with the feature.** Every user- or admin-visible change updates the pages in | |
| 72 | + `apps/diffinery/docs` (served at [diffinery.com/docs](https://diffinery.com/docs), also as | |
| 73 | + Markdown and in `llms.txt`). Write for people and for AI agents: short sentences, exact | |
| 74 | + names of buttons, settings, endpoints and flags, and tables for reference material. | |
| 75 | +- **GitHub compatibility.** Where GitHub has an API, a workflow feature or an event, | |
| 76 | + Diffinery follows it, so existing tools (git, `gh`, Octokit, actions) work unchanged. | |
| 77 | + When Diffinery has to differ, say so in the docs. | |
| 78 | +- **Database migrations.** D1 migrations live in `apps/diffinery/migrations`. Use the next | |
| 79 | + free number and never edit a migration that has shipped. | |
| 80 | +- **Security first.** Agents never get a bypass: guardrails are enforced on the server for | |
| 81 | + git, REST, MCP and the web UI alike. Never log secrets or tokens. Changes to sign-in, | |
| 82 | + tokens, permissions, secrets or the runner sandbox get extra review. | |
| 83 | +- **UI and product conventions** (passkeys first, Kumo components, no native `<select>` or | |
| 84 | + checkboxes) are in [docs/PREFERENCES.md](docs/PREFERENCES.md). | |
| 85 | + | |
| 86 | +## Pull requests | |
| 87 | + | |
| 88 | +- Keep each pull request to one change, with a title that says what it does | |
| 89 | + ("Merge queue: rebuild groups behind a failed one") and a description of why and how it | |
| 90 | + was tested. The [pull request template](.github/pull_request_template.md) has the | |
| 91 | + checklist. | |
| 92 | +- Changes to files under `.github/workflows` always need a maintainer to merge them, on | |
| 93 | + purpose: agents can't land workflow changes. | |
| 94 | +- Pull requests are squash-merged. | |
| 95 | + | |
| 96 | +## AI agents | |
| 97 | + | |
| 98 | +Agents are welcome contributors. An agent working on this repository should follow the | |
| 99 | +same conventions, run the tests before asking for review, and say in the pull request | |
| 100 | +which person it works for. The [docs for AI agents](https://diffinery.com/docs/agents/docs-for-agents) | |
| 101 | +explain how to read the documentation as Markdown and over MCP. | |
| 102 | + | |
| 103 | +## License | |
| 104 | + | |
| 105 | +Diffinery is licensed under the [Apache License 2.0](LICENSE). By contributing, you agree | |
| 106 | +that your contributions are licensed under the same license, as section 5 of the license | |
| 107 | +describes. |
addedLICENSE+202−0View file
| @@ -1,0 +1,202 @@ | ||
| 1 | + | |
| 2 | + Apache License | |
| 3 | + Version 2.0, January 2004 | |
| 4 | + http://www.apache.org/licenses/ | |
| 5 | + | |
| 6 | + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION | |
| 7 | + | |
| 8 | + 1. Definitions. | |
| 9 | + | |
| 10 | + "License" shall mean the terms and conditions for use, reproduction, | |
| 11 | + and distribution as defined by Sections 1 through 9 of this document. | |
| 12 | + | |
| 13 | + "Licensor" shall mean the copyright owner or entity authorized by | |
| 14 | + the copyright owner that is granting the License. | |
| 15 | + | |
| 16 | + "Legal Entity" shall mean the union of the acting entity and all | |
| 17 | + other entities that control, are controlled by, or are under common | |
| 18 | + control with that entity. For the purposes of this definition, | |
| 19 | + "control" means (i) the power, direct or indirect, to cause the | |
| 20 | + direction or management of such entity, whether by contract or | |
| 21 | + otherwise, or (ii) ownership of fifty percent (50%) or more of the | |
| 22 | + outstanding shares, or (iii) beneficial ownership of such entity. | |
| 23 | + | |
| 24 | + "You" (or "Your") shall mean an individual or Legal Entity | |
| 25 | + exercising permissions granted by this License. | |
| 26 | + | |
| 27 | + "Source" form shall mean the preferred form for making modifications, | |
| 28 | + including but not limited to software source code, documentation | |
| 29 | + source, and configuration files. | |
| 30 | + | |
| 31 | + "Object" form shall mean any form resulting from mechanical | |
| 32 | + transformation or translation of a Source form, including but | |
| 33 | + not limited to compiled object code, generated documentation, | |
| 34 | + and conversions to other media types. | |
| 35 | + | |
| 36 | + "Work" shall mean the work of authorship, whether in Source or | |
| 37 | + Object form, made available under the License, as indicated by a | |
| 38 | + copyright notice that is included in or attached to the work | |
| 39 | + (an example is provided in the Appendix below). | |
| 40 | + | |
| 41 | + "Derivative Works" shall mean any work, whether in Source or Object | |
| 42 | + form, that is based on (or derived from) the Work and for which the | |
| 43 | + editorial revisions, annotations, elaborations, or other modifications | |
| 44 | + represent, as a whole, an original work of authorship. For the purposes | |
| 45 | + of this License, Derivative Works shall not include works that remain | |
| 46 | + separable from, or merely link (or bind by name) to the interfaces of, | |
| 47 | + the Work and Derivative Works thereof. | |
| 48 | + | |
| 49 | + "Contribution" shall mean any work of authorship, including | |
| 50 | + the original version of the Work and any modifications or additions | |
| 51 | + to that Work or Derivative Works thereof, that is intentionally | |
| 52 | + submitted to Licensor for inclusion in the Work by the copyright owner | |
| 53 | + or by an individual or Legal Entity authorized to submit on behalf of | |
| 54 | + the copyright owner. For the purposes of this definition, "submitted" | |
| 55 | + means any form of electronic, verbal, or written communication sent | |
| 56 | + to the Licensor or its representatives, including but not limited to | |
| 57 | + communication on electronic mailing lists, source code control systems, | |
| 58 | + and issue tracking systems that are managed by, or on behalf of, the | |
| 59 | + Licensor for the purpose of discussing and improving the Work, but | |
| 60 | + excluding communication that is conspicuously marked or otherwise | |
| 61 | + designated in writing by the copyright owner as "Not a Contribution." | |
| 62 | + | |
| 63 | + "Contributor" shall mean Licensor and any individual or Legal Entity | |
| 64 | + on behalf of whom a Contribution has been received by Licensor and | |
| 65 | + subsequently incorporated within the Work. | |
| 66 | + | |
| 67 | + 2. Grant of Copyright License. Subject to the terms and conditions of | |
| 68 | + this License, each Contributor hereby grants to You a perpetual, | |
| 69 | + worldwide, non-exclusive, no-charge, royalty-free, irrevocable | |
| 70 | + copyright license to reproduce, prepare Derivative Works of, | |
| 71 | + publicly display, publicly perform, sublicense, and distribute the | |
| 72 | + Work and such Derivative Works in Source or Object form. | |
| 73 | + | |
| 74 | + 3. Grant of Patent License. Subject to the terms and conditions of | |
| 75 | + this License, each Contributor hereby grants to You a perpetual, | |
| 76 | + worldwide, non-exclusive, no-charge, royalty-free, irrevocable | |
| 77 | + (except as stated in this section) patent license to make, have made, | |
| 78 | + use, offer to sell, sell, import, and otherwise transfer the Work, | |
| 79 | + where such license applies only to those patent claims licensable | |
| 80 | + by such Contributor that are necessarily infringed by their | |
| 81 | + Contribution(s) alone or by combination of their Contribution(s) | |
| 82 | + with the Work to which such Contribution(s) was submitted. If You | |
| 83 | + institute patent litigation against any entity (including a | |
| 84 | + cross-claim or counterclaim in a lawsuit) alleging that the Work | |
| 85 | + or a Contribution incorporated within the Work constitutes direct | |
| 86 | + or contributory patent infringement, then any patent licenses | |
| 87 | + granted to You under this License for that Work shall terminate | |
| 88 | + as of the date such litigation is filed. | |
| 89 | + | |
| 90 | + 4. Redistribution. You may reproduce and distribute copies of the | |
| 91 | + Work or Derivative Works thereof in any medium, with or without | |
| 92 | + modifications, and in Source or Object form, provided that You | |
| 93 | + meet the following conditions: | |
| 94 | + | |
| 95 | + (a) You must give any other recipients of the Work or | |
| 96 | + Derivative Works a copy of this License; and | |
| 97 | + | |
| 98 | + (b) You must cause any modified files to carry prominent notices | |
| 99 | + stating that You changed the files; and | |
| 100 | + | |
| 101 | + (c) You must retain, in the Source form of any Derivative Works | |
| 102 | + that You distribute, all copyright, patent, trademark, and | |
| 103 | + attribution notices from the Source form of the Work, | |
| 104 | + excluding those notices that do not pertain to any part of | |
| 105 | + the Derivative Works; and | |
| 106 | + | |
| 107 | + (d) If the Work includes a "NOTICE" text file as part of its | |
| 108 | + distribution, then any Derivative Works that You distribute must | |
| 109 | + include a readable copy of the attribution notices contained | |
| 110 | + within such NOTICE file, excluding those notices that do not | |
| 111 | + pertain to any part of the Derivative Works, in at least one | |
| 112 | + of the following places: within a NOTICE text file distributed | |
| 113 | + as part of the Derivative Works; within the Source form or | |
| 114 | + documentation, if provided along with the Derivative Works; or, | |
| 115 | + within a display generated by the Derivative Works, if and | |
| 116 | + wherever such third-party notices normally appear. The contents | |
| 117 | + of the NOTICE file are for informational purposes only and | |
| 118 | + do not modify the License. You may add Your own attribution | |
| 119 | + notices within Derivative Works that You distribute, alongside | |
| 120 | + or as an addendum to the NOTICE text from the Work, provided | |
| 121 | + that such additional attribution notices cannot be construed | |
| 122 | + as modifying the License. | |
| 123 | + | |
| 124 | + You may add Your own copyright statement to Your modifications and | |
| 125 | + may provide additional or different license terms and conditions | |
| 126 | + for use, reproduction, or distribution of Your modifications, or | |
| 127 | + for any such Derivative Works as a whole, provided Your use, | |
| 128 | + reproduction, and distribution of the Work otherwise complies with | |
| 129 | + the conditions stated in this License. | |
| 130 | + | |
| 131 | + 5. Submission of Contributions. Unless You explicitly state otherwise, | |
| 132 | + any Contribution intentionally submitted for inclusion in the Work | |
| 133 | + by You to the Licensor shall be under the terms and conditions of | |
| 134 | + this License, without any additional terms or conditions. | |
| 135 | + Notwithstanding the above, nothing herein shall supersede or modify | |
| 136 | + the terms of any separate license agreement you may have executed | |
| 137 | + with Licensor regarding such Contributions. | |
| 138 | + | |
| 139 | + 6. Trademarks. This License does not grant permission to use the trade | |
| 140 | + names, trademarks, service marks, or product names of the Licensor, | |
| 141 | + except as required for reasonable and customary use in describing the | |
| 142 | + origin of the Work and reproducing the content of the NOTICE file. | |
| 143 | + | |
| 144 | + 7. Disclaimer of Warranty. Unless required by applicable law or | |
| 145 | + agreed to in writing, Licensor provides the Work (and each | |
| 146 | + Contributor provides its Contributions) on an "AS IS" BASIS, | |
| 147 | + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or | |
| 148 | + implied, including, without limitation, any warranties or conditions | |
| 149 | + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A | |
| 150 | + PARTICULAR PURPOSE. You are solely responsible for determining the | |
| 151 | + appropriateness of using or redistributing the Work and assume any | |
| 152 | + risks associated with Your exercise of permissions under this License. | |
| 153 | + | |
| 154 | + 8. Limitation of Liability. In no event and under no legal theory, | |
| 155 | + whether in tort (including negligence), contract, or otherwise, | |
| 156 | + unless required by applicable law (such as deliberate and grossly | |
| 157 | + negligent acts) or agreed to in writing, shall any Contributor be | |
| 158 | + liable to You for damages, including any direct, indirect, special, | |
| 159 | + incidental, or consequential damages of any character arising as a | |
| 160 | + result of this License or out of the use or inability to use the | |
| 161 | + Work (including but not limited to damages for loss of goodwill, | |
| 162 | + work stoppage, computer failure or malfunction, or any and all | |
| 163 | + other commercial damages or losses), even if such Contributor | |
| 164 | + has been advised of the possibility of such damages. | |
| 165 | + | |
| 166 | + 9. Accepting Warranty or Additional Liability. While redistributing | |
| 167 | + the Work or Derivative Works thereof, You may choose to offer, | |
| 168 | + and charge a fee for, acceptance of support, warranty, indemnity, | |
| 169 | + or other liability obligations and/or rights consistent with this | |
| 170 | + License. However, in accepting such obligations, You may act only | |
| 171 | + on Your own behalf and on Your sole responsibility, not on behalf | |
| 172 | + of any other Contributor, and only if You agree to indemnify, | |
| 173 | + defend, and hold each Contributor harmless for any liability | |
| 174 | + incurred by, or claims asserted against, such Contributor by reason | |
| 175 | + of your accepting any such warranty or additional liability. | |
| 176 | + | |
| 177 | + END OF TERMS AND CONDITIONS | |
| 178 | + | |
| 179 | + APPENDIX: How to apply the Apache License to your work. | |
| 180 | + | |
| 181 | + To apply the Apache License to your work, attach the following | |
| 182 | + boilerplate notice, with the fields enclosed by brackets "[]" | |
| 183 | + replaced with your own identifying information. (Don't include | |
| 184 | + the brackets!) The text should be enclosed in the appropriate | |
| 185 | + comment syntax for the file format. We also recommend that a | |
| 186 | + file or class name and description of purpose be included on the | |
| 187 | + same "printed page" as the copyright notice for easier | |
| 188 | + identification within third-party archives. | |
| 189 | + | |
| 190 | + Copyright [yyyy] [name of copyright owner] | |
| 191 | + | |
| 192 | + Licensed under the Apache License, Version 2.0 (the "License"); | |
| 193 | + you may not use this file except in compliance with the License. | |
| 194 | + You may obtain a copy of the License at | |
| 195 | + | |
| 196 | + http://www.apache.org/licenses/LICENSE-2.0 | |
| 197 | + | |
| 198 | + Unless required by applicable law or agreed to in writing, software | |
| 199 | + distributed under the License is distributed on an "AS IS" BASIS, | |
| 200 | + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| 201 | + See the License for the specific language governing permissions and | |
| 202 | + limitations under the License. |
addedNOTICE+14−0View file
| @@ -1,0 +1,14 @@ | ||
| 1 | +Diffinery | |
| 2 | +Copyright 2026 The Diffinery Authors | |
| 3 | + | |
| 4 | +This product is licensed under the Apache License, Version 2.0 (see LICENSE). | |
| 5 | + | |
| 6 | +This product includes software developed by third parties: | |
| 7 | + | |
| 8 | +- Phosphor Icons (https://phosphoricons.com), MIT License, Copyright (c) 2023 | |
| 9 | + Phosphor Icons. Used for the web UI's icons (apps/diffinery/src/web/icons.ui.generated.ts). | |
| 10 | +- Gradle Wrapper (https://gradle.org), Apache License 2.0, Copyright Gradle, Inc. | |
| 11 | + Used to build the JetBrains plugin (apps/jetbrains/gradle/wrapper). | |
| 12 | + | |
| 13 | +Dependencies installed through package managers keep their own licenses, listed in | |
| 14 | +their packages. |
addedREADME.md+5−0View file
| @@ -1,0 +1,5 @@ | ||
| 1 | +# community-replies | |
| 2 | + | |
| 3 | +Replies to issues and discussions on the GitHub mirror, posted as Codician by the | |
| 4 | +"GitHub community setup" workflow (scripts/github-community.mjs on main). One file per reply in | |
| 5 | +replies/; see the script for the header format. This branch is never merged or mirrored. |
addedROADMAP.md+41−0View file
| @@ -1,0 +1,41 @@ | ||
| 1 | +# Roadmap | |
| 2 | + | |
| 3 | +Diffinery is young. This page lists what it can't do yet and the directions we'd like to | |
| 4 | +take, so you can judge whether it fits you today and where help is most welcome. Nothing | |
| 5 | +here has a date. If something matters to you, open an issue or a pull request. | |
| 6 | + | |
| 7 | +## Works today | |
| 8 | + | |
| 9 | +Git hosting over HTTPS, pull requests with reviews, auto-merge and a merge queue, issues, | |
| 10 | +releases, a container registry, GitHub Actions–compatible CI on per-job Cloudflare | |
| 11 | +Containers with automatic runner sizing, self-hosted runners, a GitHub importer, the | |
| 12 | +GitHub-compatible REST and GraphQL APIs, an MCP server, agent accounts with guardrails, | |
| 13 | +sandboxes, agent tasks and agent review, the CLI, editor plugins and one-command | |
| 14 | +deployment to your own Cloudflare account. See [Features](https://diffinery.com/features). | |
| 15 | + | |
| 16 | +## Known gaps | |
| 17 | + | |
| 18 | +These are the main differences from GitHub that people ask about: | |
| 19 | + | |
| 20 | +- **Hosted macOS, Windows and Arm runners.** Hosted runners are Linux x64; other platforms | |
| 21 | + use [self-hosted runners](https://diffinery.com/docs/actions/runners#self-hosted-runners). | |
| 22 | +- **Git over SSH.** Git works over HTTPS only. | |
| 23 | +- **Forks**, and pull requests between repositories. | |
| 24 | +- **Richer reviews:** pending reviews, suggested changes and resolving threads; code-owner | |
| 25 | + reviews and signed-commit requirements in branch protection. | |
| 26 | +- **Workflow events** that are accepted but don't start runs yet, such as `issues` and | |
| 27 | + `issue_comment`. See [Compatibility](https://diffinery.com/docs/actions/compatibility). | |
| 28 | +- **Importing from Gitea, Forgejo and GitLab.** The importer reads from GitHub; other forges | |
| 29 | + move with [plain Git](https://diffinery.com/docs/migrate-from-github#move-with-plain-git). | |
| 30 | +- **Wikis, discussions and Pages-style static sites.** | |
| 31 | + | |
| 32 | +## Good places to start contributing | |
| 33 | + | |
| 34 | +- Compatibility gaps listed in | |
| 35 | + [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility): | |
| 36 | + each one comes with the GitHub behaviour to match. | |
| 37 | +- Documentation: corrections, missing examples and guides for moving from other CI systems. | |
| 38 | +- Issues labelled `good first issue` and `help wanted`. | |
| 39 | + | |
| 40 | +See [CONTRIBUTING.md](CONTRIBUTING.md) for how to build, test and send changes, and | |
| 41 | +[CHANGELOG.md](CHANGELOG.md) for what has shipped. |
addedSECURITY.md+35−0View file
| @@ -1,0 +1,35 @@ | ||
| 1 | +# Security policy | |
| 2 | + | |
| 3 | +Thank you for helping keep Diffinery and the people who use it safe. | |
| 4 | + | |
| 5 | +## Reporting a vulnerability | |
| 6 | + | |
| 7 | +**Please don't report security problems in public issues, pull requests or discussions.** | |
| 8 | + | |
| 9 | +Report them privately through the form at | |
| 10 | +[diffinery.com/security/report](https://diffinery.com/security/report). You don't need an | |
| 11 | +account. Only the maintainers can read what you send, and the form gives you a reference | |
| 12 | +number to quote if you write again. Every Diffinery instance serves its own | |
| 13 | +[`/.well-known/security.txt`](https://diffinery.com/.well-known/security.txt) with the same | |
| 14 | +form as its first contact. | |
| 15 | + | |
| 16 | +A good report says what is affected (a URL, API endpoint, git or MCP operation, CLI | |
| 17 | +command or file in this repository), the steps or a short script that reproduce it, what | |
| 18 | +an attacker could do with it, and when you would like to publish. | |
| 19 | + | |
| 20 | +The full policy, including what is in scope, safe harbour for good-faith research and the | |
| 21 | +response times we aim for, is at | |
| 22 | +[diffinery.com/docs/security/disclosure](https://diffinery.com/docs/security/disclosure). | |
| 23 | + | |
| 24 | +## Supported versions | |
| 25 | + | |
| 26 | +Diffinery is deployed continuously from `main`, and only the latest `main` gets security | |
| 27 | +fixes. If you run your own instance, update it regularly (re-run `scripts/setup.sh` or | |
| 28 | +your Deploy workflow); see | |
| 29 | +[Updating](https://diffinery.com/docs/self-hosting/deploy#updating). | |
| 30 | + | |
| 31 | +## Instances you don't run | |
| 32 | + | |
| 33 | +A problem in Diffinery's code should be reported here, even if you found it on someone | |
| 34 | +else's instance. Only test on an instance you run yourself or have permission to test. | |
| 35 | +Problems in Cloudflare's platform go to [Cloudflare](https://www.cloudflare.com/disclosure/). |
addedSUPPORT.md+18−0View file
| @@ -1,0 +1,18 @@ | ||
| 1 | +# Getting help with Diffinery | |
| 2 | + | |
| 3 | +- **How do I…?** Start with the [documentation](https://diffinery.com/docs) and the | |
| 4 | + [FAQ](https://diffinery.com/faq). Every page is also Markdown, and | |
| 5 | + [llms.txt](https://diffinery.com/llms.txt) lets an AI assistant read all of it. | |
| 6 | +- **Deploying your own instance:** [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy), | |
| 7 | + [Configuration](https://diffinery.com/docs/self-hosting/configuration) and the | |
| 8 | + troubleshooting section of the deploy guide. | |
| 9 | +- **A workflow behaves differently from GitHub Actions:** check | |
| 10 | + [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility) first, | |
| 11 | + then open an issue with the "Actions compatibility" template. | |
| 12 | +- **Bugs and feature requests:** open an issue on the | |
| 13 | + [GitHub mirror](https://github.com/codician-team/diffinery/issues) or, if you have an | |
| 14 | + account there, on [Diffinery](https://diffinery.com/codician/diffinery/issues). | |
| 15 | +- **Security problems:** never in a public issue. See [SECURITY.md](SECURITY.md). | |
| 16 | + | |
| 17 | +Diffinery is a volunteer-run open-source project with no paid support. There is no hosted | |
| 18 | +service: diffinery.com is the instance the project itself is developed on. |
addedapps/cli/install/agent-setup.sh+84−0View file
| @@ -1,0 +1,84 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Sets up an agent environment (a Claude Code cloud environment, Codex, a CI runner, a dev box) to work | |
| 3 | +# on __DIFFINERY_REPO__, as its Settings → Agent setup page on Diffinery shows: | |
| 4 | +# | |
| 5 | +# curl -fsSL __DIFFINERY_URL__/__DIFFINERY_REPO__/agent-setup.sh | bash | |
| 6 | +# | |
| 7 | +# 1. Installs the diffinery CLI and puts it on PATH (/usr/local/bin/diffinery when that's writable), | |
| 8 | +# skipping the download when the installed CLI is already the server's current build. | |
| 9 | +# 2. Runs `diffinery setup --repo __DIFFINERY_REPO__`: git and gh use the token in DIFFINERY_TOKEN (gh | |
| 10 | +# through GH_ENTERPRISE_TOKEN and GH_HOST), read from the environment each time and never written to | |
| 11 | +# disk; the repository is cloned unless it's there (DIFFINERY_NO_CLONE=1 skips that, and | |
| 12 | +# DIFFINERY_CLONE_DIR says where); then a self-test, one line per check, and a report to the page. | |
| 13 | +# | |
| 14 | +# Safe to run every time a session starts, fully non-interactive, and it never prints the token. | |
| 15 | +# This script holds no secret: Diffinery serves the same one to anyone, with the repository's name in it. | |
| 16 | +# It exits 1 when the CLI can't be installed or a check fails, so a broken setup isn't kept. Without | |
| 17 | +# DIFFINERY_TOKEN it sets up what it can, says what to add, and exits 0. | |
| 18 | + | |
| 19 | +if [ -z "${BASH_VERSION:-}" ]; then | |
| 20 | + echo "This script needs bash: curl -fsSL __DIFFINERY_URL__/__DIFFINERY_REPO__/agent-setup.sh | bash" >&2 | |
| 21 | + exit 1 | |
| 22 | +fi | |
| 23 | +set -uo pipefail | |
| 24 | + | |
| 25 | +server=__DIFFINERY_URL__ | |
| 26 | +repo=__DIFFINERY_REPO__ | |
| 27 | +cli_sha256=__DIFFINERY_CLI_SHA256__ | |
| 28 | +root=${DIFFINERY_INSTALL:-$HOME/.diffinery} | |
| 29 | +# Where the diffinery command is linked from, when it's writable (running as root). | |
| 30 | +link_dir=${DIFFINERY_LINK_DIR:-/usr/local/bin} | |
| 31 | +host=${server#*://} | |
| 32 | +page="$server/$repo/settings/agent-setup" | |
| 33 | + | |
| 34 | +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then | |
| 35 | + green=$'\033[32m' red=$'\033[31m' bold=$'\033[1m' reset=$'\033[0m' | |
| 36 | +else | |
| 37 | + green='' red='' bold='' reset='' | |
| 38 | +fi | |
| 39 | +say() { printf '%s\n' "$*"; } | |
| 40 | +fail() { | |
| 41 | + say "${red}✗${reset} $*" | |
| 42 | + exit 1 | |
| 43 | +} | |
| 44 | + | |
| 45 | +say "${bold}Diffinery agent setup for $repo${reset}" | |
| 46 | +command -v curl >/dev/null || fail "curl is required" | |
| 47 | +command -v git >/dev/null || fail "git is required: install it in the setup script first (apt-get install -y git)" | |
| 48 | +original_path=$PATH | |
| 49 | + | |
| 50 | +sha256() { if command -v sha256sum >/dev/null; then sha256sum "$1"; else shasum -a 256 "$1"; fi | cut -d' ' -f1; } | |
| 51 | + | |
| 52 | +# 1. The CLI. | |
| 53 | +installed=$root/lib/diffinery.mjs | |
| 54 | +if [ -f "$installed" ] && [ "$(sha256 "$installed")" = "$cli_sha256" ] && "$root/bin/diffinery" --version >/dev/null 2>&1; then | |
| 55 | + say "${green}✓${reset} diffinery CLI: already the current build" | |
| 56 | +else | |
| 57 | + # With /usr/local/bin to link from (root in a container or VM), the CLI leaves startup files alone. | |
| 58 | + no_rc=${DIFFINERY_NO_MODIFY_PATH:-} | |
| 59 | + if [ -d "$link_dir" ] && [ -w "$link_dir" ]; then no_rc=1; fi | |
| 60 | + installer=$(curl -fsSL "$server/install") || fail "couldn't download $server/install: allow $host in the environment's network settings (Custom with $host, or Full)" | |
| 61 | + # The installer's own next steps (sign in through a browser, PATH) are for people: shown only if it fails. | |
| 62 | + if ! out=$(printf '%s\n' "$installer" | DIFFINERY_URL=$server DIFFINERY_INSTALL=$root DIFFINERY_NO_MODIFY_PATH=$no_rc bash 2>&1); then | |
| 63 | + printf '%s\n' "$out" | |
| 64 | + fail "the diffinery CLI didn't install (see above)" | |
| 65 | + fi | |
| 66 | + say "${green}✓${reset} diffinery CLI: installed $("$root/bin/diffinery" --version 2>/dev/null)" | |
| 67 | +fi | |
| 68 | +if [ -d "$link_dir" ] && [ -w "$link_dir" ]; then | |
| 69 | + ln -sfn "$root/bin/diffinery" "$link_dir/diffinery" || fail "couldn't link $link_dir/diffinery" | |
| 70 | +fi | |
| 71 | + | |
| 72 | +# 2. git, gh, the clone and the self-test. The original PATH is what the PATH check looks at. | |
| 73 | +export PATH="$root/bin:$PATH" | |
| 74 | +if [ -z "${DIFFINERY_TOKEN:-}" ]; then | |
| 75 | + # Without the token git still gets its credential helper, which reads DIFFINERY_TOKEN once it's set. | |
| 76 | + DIFFINERY_URL=$server diffinery auth setup-git >/dev/null 2>&1 || true | |
| 77 | + say "${red}✗${reset} DIFFINERY_TOKEN is not set: generate a token on $page and add the Environment variables shown there." | |
| 78 | + say " git and the CLI are set up and will use the token once it's in the environment; run this again then to check." | |
| 79 | + exit 0 | |
| 80 | +fi | |
| 81 | +if [ -n "${DIFFINERY_REPO:-}" ] && [ "$DIFFINERY_REPO" != "$repo" ]; then | |
| 82 | + say "note: DIFFINERY_REPO is $DIFFINERY_REPO, but this script sets up $repo" | |
| 83 | +fi | |
| 84 | +DIFFINERY_SETUP_PATH=$original_path diffinery setup --repo "$repo" --url "$server" |
addedapps/cli/install/install.ps1+108−0View file
| @@ -1,0 +1,108 @@ | ||
| 1 | +# Installs the Diffinery CLI on Windows: | |
| 2 | +# | |
| 3 | +# powershell -c "irm https://diffinery.com/install.ps1 | iex" | |
| 4 | +# | |
| 5 | +# The CLI goes in %USERPROFILE%\.diffinery (set DIFFINERY_INSTALL to change that), and its bin | |
| 6 | +# folder is added to your user PATH (DIFFINERY_NO_MODIFY_PATH=1 skips that). It runs on Node.js 22 | |
| 7 | +# or later: the one on your PATH when it's new enough, otherwise a private copy of the Node.js LTS | |
| 8 | +# from nodejs.org, checked against its published SHA-256, in .diffinery\node. | |
| 9 | +# Run it again, or `diffinery upgrade`, to update. Served by Diffinery from apps/cli/install/. | |
| 10 | +# `irm | iex` runs this in your session: the block keeps its settings and variables out of it. | |
| 11 | +& { | |
| 12 | + $ErrorActionPreference = 'Stop' | |
| 13 | + # Invoke-WebRequest is many times faster without its progress bar. | |
| 14 | + $ProgressPreference = 'SilentlyContinue' | |
| 15 | + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 | |
| 16 | + | |
| 17 | + $Server = if ($env:DIFFINERY_URL) { $env:DIFFINERY_URL } else { '__DIFFINERY_URL__' } | |
| 18 | + if ($Server.StartsWith('__')) { $Server = 'https://diffinery.com' } | |
| 19 | + $Server = $Server.TrimEnd('/') | |
| 20 | + $Root = if ($env:DIFFINERY_INSTALL) { $env:DIFFINERY_INSTALL } else { Join-Path $HOME '.diffinery' } | |
| 21 | + $Bin = Join-Path $Root 'bin' | |
| 22 | + $Lib = Join-Path $Root 'lib' | |
| 23 | + $Private = Join-Path $Root 'node\node.exe' | |
| 24 | + $NodeLine = 24 | |
| 25 | + | |
| 26 | + function Test-Node([string] $Exe) { | |
| 27 | + try { | |
| 28 | + & $Exe -e "process.exit(Number(process.versions.node.split('.')[0]) >= 22 ? 0 : 1)" *> $null | |
| 29 | + return $LASTEXITCODE -eq 0 | |
| 30 | + } catch { | |
| 31 | + return $false | |
| 32 | + } | |
| 33 | + } | |
| 34 | + | |
| 35 | + # 1. Node.js for the CLI to run on. | |
| 36 | + $OwnNode = $false | |
| 37 | + $SystemNode = Get-Command node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 | |
| 38 | + if ((Test-Path $Private) -and (Test-Node $Private)) { | |
| 39 | + $OwnNode = $true | |
| 40 | + } elseif (-not ($SystemNode -and (Test-Node $SystemNode.Source))) { | |
| 41 | + $Arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64') { 'arm64' } else { 'x64' } | |
| 42 | + Write-Host "Installing Node.js $NodeLine LTS for the CLI, from nodejs.org..." | |
| 43 | + $Dist = "https://nodejs.org/dist/latest-v$NodeLine.x" | |
| 44 | + $Sums = [string] (Invoke-WebRequest -UseBasicParsing "$Dist/SHASUMS256.txt").Content | |
| 45 | + $Line = $Sums -split "`n" | Where-Object { $_ -match "^[0-9a-f]{64} node-v[0-9.]+-win-$Arch\.zip$" } | Select-Object -First 1 | |
| 46 | + if (-not $Line) { throw "nodejs.org has no Node.js build for win-$Arch" } | |
| 47 | + $Want, $File = $Line.Trim() -split '\s+' | |
| 48 | + $Tmp = Join-Path ([IO.Path]::GetTempPath()) ("diffinery-" + [guid]::NewGuid().ToString('n')) | |
| 49 | + New-Item -ItemType Directory -Path $Tmp | Out-Null | |
| 50 | + try { | |
| 51 | + $Zip = Join-Path $Tmp $File | |
| 52 | + Invoke-WebRequest -UseBasicParsing "$Dist/$File" -OutFile $Zip | |
| 53 | + if ((Get-FileHash -Algorithm SHA256 $Zip).Hash.ToLower() -ne $Want) { throw "$File doesn't match its published SHA-256, so it wasn't installed" } | |
| 54 | + Expand-Archive -Path $Zip -DestinationPath $Tmp | |
| 55 | + $Dest = Join-Path $Root 'node' | |
| 56 | + New-Item -ItemType Directory -Force -Path $Root | Out-Null | |
| 57 | + if (Test-Path $Dest) { Remove-Item -Recurse -Force $Dest } | |
| 58 | + Move-Item (Join-Path $Tmp ($File -replace '\.zip$', '')) $Dest | |
| 59 | + } finally { | |
| 60 | + Remove-Item -Recurse -Force $Tmp -ErrorAction SilentlyContinue | |
| 61 | + } | |
| 62 | + $OwnNode = $true | |
| 63 | + } | |
| 64 | + | |
| 65 | + # 2. The CLI itself: one JavaScript file. | |
| 66 | + New-Item -ItemType Directory -Force -Path $Bin, $Lib | Out-Null | |
| 67 | + $Cli = Join-Path $Lib 'diffinery.mjs' | |
| 68 | + $Download = "$Cli.download" | |
| 69 | + Invoke-WebRequest -UseBasicParsing "$Server/-/cli/diffinery.mjs" -OutFile $Download | |
| 70 | + if (-not ((Get-Content $Download -TotalCount 1) -like '#!/usr/bin/env node*')) { | |
| 71 | + Remove-Item -Force $Download | |
| 72 | + throw "$Server/-/cli/diffinery.mjs isn't the CLI" | |
| 73 | + } | |
| 74 | + Move-Item -Force $Download $Cli | |
| 75 | + | |
| 76 | + # 3. The diffinery command: the private Node.js when there is one, else the node on PATH. | |
| 77 | + $Cmd = @( | |
| 78 | + '@echo off', | |
| 79 | + "rem The Diffinery CLI, installed from $Server/install.ps1. Update it with: diffinery upgrade", | |
| 80 | + 'setlocal', | |
| 81 | + 'set "DIFFINERY_NODE=node"', | |
| 82 | + 'if exist "%~dp0..\node\node.exe" set "DIFFINERY_NODE=%~dp0..\node\node.exe"', | |
| 83 | + '"%DIFFINERY_NODE%" "%~dp0..\lib\diffinery.mjs" %*' | |
| 84 | + ) -join "`r`n" | |
| 85 | + Set-Content -Path (Join-Path $Bin 'diffinery.cmd') -Value $Cmd -Encoding ASCII | |
| 86 | + $Version = & (Join-Path $Bin 'diffinery.cmd') --version | |
| 87 | + if ($LASTEXITCODE -ne 0) { throw "the CLI was installed in $Root but doesn't start; run $Bin\diffinery.cmd --version to see why" } | |
| 88 | + | |
| 89 | + # 4. PATH, for new terminals and this one. | |
| 90 | + $Note = $null | |
| 91 | + $UserPath = [Environment]::GetEnvironmentVariable('Path', 'User') | |
| 92 | + if (-not (($UserPath -split ';') -contains $Bin)) { | |
| 93 | + if ($env:DIFFINERY_NO_MODIFY_PATH) { | |
| 94 | + $Note = "Add $Bin to your PATH to run diffinery." | |
| 95 | + } else { | |
| 96 | + [Environment]::SetEnvironmentVariable('Path', ((@($UserPath, $Bin) | Where-Object { $_ }) -join ';'), 'User') | |
| 97 | + $Note = "Added $Bin to your user PATH. Open a new terminal to use it everywhere." | |
| 98 | + } | |
| 99 | + } | |
| 100 | + if (-not (($env:Path -split ';') -contains $Bin)) { $env:Path = "$Bin;$env:Path" } | |
| 101 | + | |
| 102 | + Write-Host "$([char]0x2713) $Version installed to $Bin\diffinery.cmd$(if ($OwnNode) { ' (with its own Node.js)' })" -ForegroundColor Green | |
| 103 | + if ($Note) { Write-Host $Note } | |
| 104 | + $Login = if ($Server -eq 'https://diffinery.com') { 'diffinery login' } else { "diffinery login --url $Server" } | |
| 105 | + Write-Host "" | |
| 106 | + Write-Host "Next, sign in through your browser:" | |
| 107 | + Write-Host " $Login" | |
| 108 | +} |
addedapps/cli/install/install.sh+182−0View file
| @@ -1,0 +1,182 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Installs the Diffinery CLI on Linux and macOS: | |
| 3 | +# | |
| 4 | +# curl -fsSL https://diffinery.com/install | bash | |
| 5 | +# | |
| 6 | +# The CLI goes in ~/.diffinery (set DIFFINERY_INSTALL to change that), and ~/.diffinery/bin is | |
| 7 | +# added to PATH in your shells' startup files, with a link in ~/.local/bin or ~/bin when one of | |
| 8 | +# those is on PATH already so it works at once (DIFFINERY_NO_MODIFY_PATH=1 skips both). It runs on | |
| 9 | +# Node.js 22 or later: the one on your PATH when it's new enough, otherwise a private copy of the | |
| 10 | +# Node.js LTS from nodejs.org, checked against its published SHA-256, in ~/.diffinery/node. | |
| 11 | +# Run it again, or `diffinery upgrade`, to update. Served by Diffinery from apps/cli/install/. | |
| 12 | + | |
| 13 | +# Piped into sh instead of bash, a shell like dash stops at `set -o pipefail` with no hint why. | |
| 14 | +if [ -z "${BASH_VERSION:-}" ]; then | |
| 15 | + echo "This installer needs bash: curl -fsSL __DIFFINERY_URL__/install | bash" >&2 | |
| 16 | + exit 1 | |
| 17 | +fi | |
| 18 | +set -euo pipefail | |
| 19 | + | |
| 20 | +server=${DIFFINERY_URL:-__DIFFINERY_URL__} | |
| 21 | +case $server in __*) server=https://diffinery.com ;; esac | |
| 22 | +server=${server%/} | |
| 23 | +root=${DIFFINERY_INSTALL:-$HOME/.diffinery} | |
| 24 | +bin=$root/bin | |
| 25 | +node_line=24 | |
| 26 | + | |
| 27 | +if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then | |
| 28 | + bold=$'\033[1m' dim=$'\033[2m' green=$'\033[32m' red=$'\033[31m' reset=$'\033[0m' | |
| 29 | +else | |
| 30 | + bold='' dim='' green='' red='' reset='' | |
| 31 | +fi | |
| 32 | +say() { printf '%s\n' "$*" >&2; } | |
| 33 | +die() { | |
| 34 | + say "${red}error${reset}: $*" | |
| 35 | + exit 1 | |
| 36 | +} | |
| 37 | +tilde() { case $1 in "$HOME"/*) printf '~%s' "${1#"$HOME"}" ;; *) printf '%s' "$1" ;; esac; } | |
| 38 | + | |
| 39 | +command -v curl >/dev/null || die "curl is required" | |
| 40 | +case $(uname -s) in | |
| 41 | + Linux) os=linux ;; | |
| 42 | + Darwin) os=darwin ;; | |
| 43 | + MINGW* | MSYS* | CYGWIN*) die "on Windows, run this in PowerShell instead: irm $server/install.ps1 | iex" ;; | |
| 44 | + *) die "$(uname -s) isn't supported by this script; install Node.js 22 or later and download $server/-/cli/diffinery.mjs" ;; | |
| 45 | +esac | |
| 46 | +case $(uname -m) in | |
| 47 | + x86_64 | amd64) arch=x64 ;; | |
| 48 | + arm64 | aarch64) arch=arm64 ;; | |
| 49 | + *) arch='' ;; | |
| 50 | +esac | |
| 51 | +# A shell under Rosetta on Apple silicon still gets the arm64 Node.js. | |
| 52 | +if [ "$os" = darwin ] && [ "$arch" = x64 ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || true)" = 1 ]; then arch=arm64; fi | |
| 53 | + | |
| 54 | +tmp=$(mktemp -d) | |
| 55 | +trap 'rm -rf "$tmp"' EXIT | |
| 56 | + | |
| 57 | +node_ok() { "$1" -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)' >/dev/null 2>&1; } | |
| 58 | + | |
| 59 | +# 1. Node.js for the CLI to run on. | |
| 60 | +private=$root/node/bin/node | |
| 61 | +if [ -x "$private" ] && node_ok "$private"; then | |
| 62 | + node=$private | |
| 63 | +elif command -v node >/dev/null && node_ok node; then | |
| 64 | + node='' | |
| 65 | +else | |
| 66 | + [ -n "$arch" ] || die "the CLI needs Node.js 22 or later, and nodejs.org has no build for $(uname -m); install Node.js and run this again" | |
| 67 | + if [ "$os" = linux ] && ldd --version 2>&1 | grep -qi musl; then die "the CLI needs Node.js 22 or later; on Alpine and other musl systems install it with your package manager (apk add nodejs) and run this again"; fi | |
| 68 | + say "Installing Node.js $node_line LTS for the CLI, from nodejs.org…" | |
| 69 | + dist=https://nodejs.org/dist/latest-v$node_line.x | |
| 70 | + sums=$(curl -fsSL "$dist/SHASUMS256.txt") || die "couldn't reach nodejs.org" | |
| 71 | + line=$(printf '%s\n' "$sums" | grep -E " node-v[0-9.]+-$os-$arch\.tar\.gz$" | head -n 1 || true) | |
| 72 | + [ -n "$line" ] || die "nodejs.org has no Node.js build for $os-$arch" | |
| 73 | + want=${line%% *} | |
| 74 | + file=${line##* } | |
| 75 | + curl -fsSL "$dist/$file" -o "$tmp/node.tar.gz" || die "couldn't download $file" | |
| 76 | + if command -v sha256sum >/dev/null; then got=$(sha256sum "$tmp/node.tar.gz"); else got=$(shasum -a 256 "$tmp/node.tar.gz"); fi | |
| 77 | + [ "${got%% *}" = "$want" ] || die "$file doesn't match its published SHA-256, so it wasn't installed" | |
| 78 | + mkdir -p "$tmp/node" "$root" | |
| 79 | + tar -xzf "$tmp/node.tar.gz" -C "$tmp/node" --strip-components 1 | |
| 80 | + rm -rf "$root/node" | |
| 81 | + mv "$tmp/node" "$root/node" | |
| 82 | + node=$private | |
| 83 | +fi | |
| 84 | + | |
| 85 | +# 2. The CLI itself: one JavaScript file. | |
| 86 | +curl -fsSL "$server/-/cli/diffinery.mjs" -o "$tmp/diffinery.mjs" || die "couldn't download the CLI from $server" | |
| 87 | +head -n 1 "$tmp/diffinery.mjs" | grep -q '^#!/usr/bin/env node' || die "$server/-/cli/diffinery.mjs isn't the CLI" | |
| 88 | +mkdir -p "$root/lib" "$bin" | |
| 89 | +chmod 755 "$tmp/diffinery.mjs" | |
| 90 | +mv -f "$tmp/diffinery.mjs" "$root/lib/diffinery.mjs" | |
| 91 | + | |
| 92 | +# 3. The diffinery command: the private Node.js when there is one, else the node on PATH. | |
| 93 | +q=$(printf '%q' "$root") | |
| 94 | +cat >"$tmp/diffinery" <<EOF | |
| 95 | +#!/bin/sh | |
| 96 | +# The Diffinery CLI, installed from $server/install. Update it with: diffinery upgrade | |
| 97 | +root=$q | |
| 98 | +node=\$root/node/bin/node | |
| 99 | +[ -x "\$node" ] || node=node | |
| 100 | +exec "\$node" "\$root/lib/diffinery.mjs" "\$@" | |
| 101 | +EOF | |
| 102 | +chmod 755 "$tmp/diffinery" | |
| 103 | +mv -f "$tmp/diffinery" "$bin/diffinery" | |
| 104 | +version=$("$bin/diffinery" --version) || die "the CLI was installed in $(tilde "$root") but doesn't start; run $bin/diffinery --version to see why" | |
| 105 | + | |
| 106 | +# 4. PATH: in startup files for new terminals, plus a link in a directory already on PATH (such as | |
| 107 | +# ~/.local/bin) so the command works right away, in this terminal and in agents' shells too. | |
| 108 | +added='' kept='' ready='' linked='' | |
| 109 | +# As written in startup files: $HOME/.diffinery/bin rather than the expanded path, when it's in $HOME. | |
| 110 | +case $bin in "$HOME"/*) rc_bin='$HOME'${bin#"$HOME"} ;; *) rc_bin=$bin ;; esac | |
| 111 | +on_path() { case ":$PATH:" in *":$1:"*) return 0 ;; esac; return 1; } | |
| 112 | +# Adds the line for this kind of file, once: sh syntax for bash, zsh and sh, fish's own for fish. | |
| 113 | +add_to() { | |
| 114 | + local rc=$1 line | |
| 115 | + case $rc in | |
| 116 | + *.fish) line="fish_add_path \"$rc_bin\"" ;; | |
| 117 | + *) line="case \":\$PATH:\" in *\":$rc_bin:\"*) ;; *) export PATH=\"$rc_bin:\$PATH\" ;; esac" ;; | |
| 118 | + esac | |
| 119 | + if grep -qsF -e "$bin" -e "$rc_bin" "$rc"; then kept=${kept:+$kept, }$(tilde "$rc"); return; fi | |
| 120 | + mkdir -p "$(dirname "$rc")" | |
| 121 | + printf '\n# Diffinery CLI\n%s\n' "$line" >>"$rc" | |
| 122 | + added=${added:+$added, }$(tilde "$rc") | |
| 123 | +} | |
| 124 | +if on_path "$bin"; then | |
| 125 | + ready=1 | |
| 126 | +elif [ -z "${DIFFINERY_NO_MODIFY_PATH:-}" ]; then | |
| 127 | + # The startup files of the login shell, created if missing, and of any other shell already set | |
| 128 | + # up here. Bash reads only the first of ~/.bash_profile, ~/.bash_login and ~/.profile in a login | |
| 129 | + # shell (every macOS terminal), so the line goes in the one it reads, never a new one that hides it. | |
| 130 | + shell=$(basename "${SHELL:-sh}") | |
| 131 | + zrc=${ZDOTDIR:-$HOME}/.zshrc | |
| 132 | + bash_login='' | |
| 133 | + for f in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do | |
| 134 | + if [ -f "$f" ]; then bash_login=$f; break; fi | |
| 135 | + done | |
| 136 | + rcs=() | |
| 137 | + case $shell in | |
| 138 | + zsh) rcs+=("$zrc") ;; | |
| 139 | + bash) rcs+=("$HOME/.bashrc" "${bash_login:-$([ "$os" = darwin ] && echo "$HOME/.bash_profile" || echo "$HOME/.profile")}") ;; | |
| 140 | + fish) rcs+=("$HOME/.config/fish/conf.d/diffinery.fish") ;; | |
| 141 | + *) rcs+=("$HOME/.profile") ;; | |
| 142 | + esac | |
| 143 | + [ "$shell" = zsh ] || [ ! -f "$zrc" ] || rcs+=("$zrc") | |
| 144 | + [ "$shell" = bash ] || [ ! -f "$HOME/.bashrc" ] || rcs+=("$HOME/.bashrc") | |
| 145 | + [ "$shell" = fish ] || [ ! -d "$HOME/.config/fish" ] || rcs+=("$HOME/.config/fish/conf.d/diffinery.fish") | |
| 146 | + for rc in "${rcs[@]}"; do add_to "$rc"; done | |
| 147 | + # A link from a personal bin directory that's already on PATH, unless something else lives there. | |
| 148 | + for dir in "$HOME/.local/bin" "$HOME/bin"; do | |
| 149 | + if on_path "$dir" && [ -d "$dir" ] && [ -w "$dir" ]; then | |
| 150 | + if [ "$(readlink "$dir/diffinery" 2>/dev/null || true)" = "$bin/diffinery" ] || { [ ! -e "$dir/diffinery" ] && [ ! -L "$dir/diffinery" ]; }; then | |
| 151 | + ln -sfn "$bin/diffinery" "$dir/diffinery" | |
| 152 | + linked=$dir/diffinery ready=1 | |
| 153 | + fi | |
| 154 | + break | |
| 155 | + fi | |
| 156 | + done | |
| 157 | + # In a workflow job, later steps get it on PATH too. | |
| 158 | + if [ -n "${GITHUB_PATH:-}" ] && [ -w "${GITHUB_PATH}" ]; then echo "$bin" >>"$GITHUB_PATH"; fi | |
| 159 | +fi | |
| 160 | + | |
| 161 | +say "${green}✓${reset} ${version} installed to $(tilde "$bin/diffinery")${node:+ ${dim}(with its own Node.js)${reset}}" | |
| 162 | +if [ -n "$added" ]; then | |
| 163 | + say "Added $(tilde "$bin") to PATH in $added." | |
| 164 | +elif [ -n "$kept" ]; then | |
| 165 | + say "New terminals already get $(tilde "$bin") on PATH from $kept." | |
| 166 | +fi | |
| 167 | +[ -z "$linked" ] || say "Linked it from $(tilde "$linked"), so diffinery works in this terminal now." | |
| 168 | +if [ -z "$ready" ]; then | |
| 169 | + if [ -n "${DIFFINERY_NO_MODIFY_PATH:-}" ]; then | |
| 170 | + say "Add $(tilde "$bin") to your PATH to run diffinery." | |
| 171 | + else | |
| 172 | + say "Open a new terminal, or run this to use diffinery in this one:" | |
| 173 | + say " ${bold}export PATH=\"$rc_bin:\$PATH\"${reset}" | |
| 174 | + fi | |
| 175 | +fi | |
| 176 | +other=$(command -v diffinery 2>/dev/null || true) | |
| 177 | +if [ -n "$other" ] && [ "$other" != "$bin/diffinery" ] && [ "$(readlink "$other" 2>/dev/null || true)" != "$bin/diffinery" ]; then say "${dim}Another diffinery at $(tilde "$other") comes first on your PATH; remove it to use this one.${reset}"; fi | |
| 178 | +setup="diffinery setup" | |
| 179 | +[ "$server" = https://diffinery.com ] || setup="diffinery setup --url $server" | |
| 180 | +say "" | |
| 181 | +say "Next, sign in through your browser and set up git (and gh, if you have it):" | |
| 182 | +say " ${bold}$setup${reset}" |
addedapps/cli/package.json+18−0View file
| @@ -1,0 +1,18 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@diffinery/cli", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "license": "Apache-2.0", | |
| 6 | + "description": "Command-line client for Diffinery", | |
| 7 | + "type": "module", | |
| 8 | + "bin": { "diffinery": "dist/diffinery.mjs" }, | |
| 9 | + "files": ["dist"], | |
| 10 | + "engines": { "node": ">=22" }, | |
| 11 | + "scripts": { | |
| 12 | + "build": "esbuild src/bin.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/diffinery.mjs \"--banner:js=#!/usr/bin/env node\nimport { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);\"", | |
| 13 | + "typecheck": "tsc --noEmit", | |
| 14 | + "test": "vitest run" | |
| 15 | + }, | |
| 16 | + "dependencies": { "@diffinery/workflow-yaml": "workspace:*" }, | |
| 17 | + "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.0", "esbuild": "^0.25.0", "@types/node": "^22.0.0" } | |
| 18 | +} |
addedapps/cli/src/api.ts+96−0View file
| @@ -1,0 +1,96 @@ | ||
| 1 | +import { CliError } from "./io.js"; | |
| 2 | +import { VERSION } from "./version.js"; | |
| 3 | + | |
| 4 | +export class ApiError extends CliError { | |
| 5 | + constructor( | |
| 6 | + readonly status: number, | |
| 7 | + message: string, | |
| 8 | + readonly body: unknown, | |
| 9 | + ) { | |
| 10 | + super(message); | |
| 11 | + } | |
| 12 | +} | |
| 13 | + | |
| 14 | +export interface RequestOptions { | |
| 15 | + body?: unknown; | |
| 16 | + query?: Record<string, string | number | boolean | undefined | null>; | |
| 17 | + /** Return the response text instead of parsing JSON. */ | |
| 18 | + text?: boolean; | |
| 19 | + /** Treat these statuses as success (e.g. 404 when probing existence). */ | |
| 20 | + allow?: number[]; | |
| 21 | + headers?: Record<string, string>; | |
| 22 | +} | |
| 23 | + | |
| 24 | +export interface ApiResponse<T> { | |
| 25 | + status: number; | |
| 26 | + data: T; | |
| 27 | + headers: Headers; | |
| 28 | +} | |
| 29 | + | |
| 30 | +/** Thin client for the Diffinery REST API (`/api/v3`, GitHub-shaped). */ | |
| 31 | +export class Api { | |
| 32 | + constructor( | |
| 33 | + readonly baseUrl: string, | |
| 34 | + readonly token: string | null, | |
| 35 | + ) {} | |
| 36 | + | |
| 37 | + url(path: string, query?: RequestOptions["query"]): string { | |
| 38 | + const p = /^https?:\/\//.test(path) ? path : `${this.baseUrl}${path.startsWith("/api/") || path.startsWith("/-/") || path === "/mcp" ? "" : "/api/v3"}${path.startsWith("/") ? path : `/${path}`}`; | |
| 39 | + const u = new URL(p); | |
| 40 | + for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined && v !== null && v !== "") u.searchParams.set(k, String(v)); | |
| 41 | + return u.toString(); | |
| 42 | + } | |
| 43 | + | |
| 44 | + async raw<T = unknown>(method: string, path: string, o: RequestOptions = {}): Promise<ApiResponse<T>> { | |
| 45 | + const headers: Record<string, string> = { accept: "application/json", "user-agent": `diffinery-cli/${VERSION}`, ...o.headers }; | |
| 46 | + if (this.token) headers.authorization = `Bearer ${this.token}`; | |
| 47 | + let body: string | undefined; | |
| 48 | + if (o.body !== undefined) { | |
| 49 | + body = typeof o.body === "string" ? o.body : JSON.stringify(o.body); | |
| 50 | + headers["content-type"] ??= "application/json"; | |
| 51 | + } | |
| 52 | + let res: Response; | |
| 53 | + const url = this.url(path, o.query); | |
| 54 | + try { | |
| 55 | + res = await fetch(url, { method, headers, body }); | |
| 56 | + } catch (e) { | |
| 57 | + const cause = (e as { cause?: { code?: string; message?: string } }).cause; | |
| 58 | + throw new CliError(`could not reach ${new URL(url).origin}: ${cause?.code ?? cause?.message ?? (e as Error).message}`); | |
| 59 | + } | |
| 60 | + const text = await res.text(); | |
| 61 | + let data: unknown = text; | |
| 62 | + if (!o.text && text && (res.headers.get("content-type") ?? "").includes("json")) { | |
| 63 | + try { | |
| 64 | + data = JSON.parse(text); | |
| 65 | + } catch { | |
| 66 | + /* leave as text */ | |
| 67 | + } | |
| 68 | + } else if (!o.text && !text) data = null; | |
| 69 | + if (!res.ok && !o.allow?.includes(res.status)) { | |
| 70 | + const msg = data && typeof data === "object" && "message" in data ? String((data as { message: unknown }).message) : text.slice(0, 300) || res.statusText; | |
| 71 | + let hint = ""; | |
| 72 | + if (res.status === 401) hint = this.token ? " (token invalid or expired; run `diffinery login`)" : " (not logged in; run `diffinery login`)"; | |
| 73 | + else if (res.status === 403 && /needs the (write|admin) scope/.test(msg)) hint = ` (run \`diffinery login --scope ${/admin/.test(msg) ? "admin" : "write"}\` to sign in with it)`; | |
| 74 | + throw new ApiError(res.status, `${method} ${new URL(url).pathname}: ${res.status} ${msg}${hint}`, data); | |
| 75 | + } | |
| 76 | + return { status: res.status, data: data as T, headers: res.headers }; | |
| 77 | + } | |
| 78 | + | |
| 79 | + async get<T = unknown>(path: string, query?: RequestOptions["query"]): Promise<T> { | |
| 80 | + return (await this.raw<T>("GET", path, { query })).data; | |
| 81 | + } | |
| 82 | + async post<T = unknown>(path: string, body?: unknown): Promise<T> { | |
| 83 | + return (await this.raw<T>("POST", path, { body: body ?? {} })).data; | |
| 84 | + } | |
| 85 | + async put<T = unknown>(path: string, body?: unknown): Promise<T> { | |
| 86 | + return (await this.raw<T>("PUT", path, { body: body ?? {} })).data; | |
| 87 | + } | |
| 88 | + async patch<T = unknown>(path: string, body: unknown): Promise<T> { | |
| 89 | + return (await this.raw<T>("PATCH", path, { body })).data; | |
| 90 | + } | |
| 91 | + async delete<T = unknown>(path: string): Promise<T> { | |
| 92 | + return (await this.raw<T>("DELETE", path)).data; | |
| 93 | + } | |
| 94 | +} | |
| 95 | + | |
| 96 | +export const enc = encodeURIComponent; |
addedapps/cli/src/bin.ts+22−0View file
| @@ -1,0 +1,22 @@ | ||
| 1 | +import { main } from "./main.js"; | |
| 2 | + | |
| 3 | +// The bundle targets Node.js 22; on an older one, say so instead of failing somewhere obscure. | |
| 4 | +if (Number(process.versions.node.split(".")[0]) < 22) { | |
| 5 | + process.stderr.write(`error: diffinery needs Node.js 22 or later (this is ${process.version}). Reinstall with: curl -fsSL https://diffinery.com/install | bash\n`); | |
| 6 | + process.exit(1); | |
| 7 | +} | |
| 8 | + | |
| 9 | +// EPIPE when piping into `head` and similar should end quietly. | |
| 10 | +process.stdout.on("error", (e: NodeJS.ErrnoException) => { | |
| 11 | + if (e.code === "EPIPE") process.exit(0); | |
| 12 | +}); | |
| 13 | + | |
| 14 | +main(process.argv.slice(2)).then( | |
| 15 | + (code) => { | |
| 16 | + process.exitCode = code; | |
| 17 | + }, | |
| 18 | + (e: unknown) => { | |
| 19 | + process.stderr.write(`error: ${String(e)}\n`); | |
| 20 | + process.exitCode = 1; | |
| 21 | + }, | |
| 22 | +); |
addedapps/cli/src/cli.ts+261−0View file
| @@ -1,0 +1,261 @@ | ||
| 1 | +import { parseArgs } from "node:util"; | |
| 2 | +import { Api } from "./api.js"; | |
| 3 | +import { resolveConfig, type ResolvedConfig } from "./config.js"; | |
| 4 | +import { gitCapture, parseRemote } from "./git.js"; | |
| 5 | +import { CliError, ExitCode, type Io } from "./io.js"; | |
| 6 | +import { colors, type Colors } from "./output.js"; | |
| 7 | +import { VERSION } from "./version.js"; | |
| 8 | + | |
| 9 | +export interface OptSpec { | |
| 10 | + type: "string" | "boolean"; | |
| 11 | + short?: string; | |
| 12 | + multiple?: boolean; | |
| 13 | + desc: string; | |
| 14 | + /** Placeholder shown in help, e.g. "NAME". */ | |
| 15 | + arg?: string; | |
| 16 | +} | |
| 17 | + | |
| 18 | +export type Values = Record<string, string | boolean | string[] | undefined>; | |
| 19 | + | |
| 20 | +export interface Command { | |
| 21 | + /** Full command path, e.g. "pr create". */ | |
| 22 | + name: string; | |
| 23 | + summary: string; | |
| 24 | + /** Positional part of the usage line, e.g. "<number>". */ | |
| 25 | + args?: string; | |
| 26 | + description?: string; | |
| 27 | + options?: Record<string, OptSpec>; | |
| 28 | + /** Accepts -R/--repo OWNER/NAME (defaults to the git origin). */ | |
| 29 | + repo?: boolean; | |
| 30 | + /** Accepts --json. */ | |
| 31 | + json?: boolean; | |
| 32 | + minArgs?: number; | |
| 33 | + maxArgs?: number; | |
| 34 | + run(ctx: Ctx, o: Values, args: string[]): Promise<number | void>; | |
| 35 | +} | |
| 36 | + | |
| 37 | +export interface RepoRef { | |
| 38 | + owner: string; | |
| 39 | + name: string; | |
| 40 | +} | |
| 41 | + | |
| 42 | +export class Ctx { | |
| 43 | + readonly c: Colors; | |
| 44 | + private cfg?: Promise<ResolvedConfig>; | |
| 45 | + constructor( | |
| 46 | + readonly io: Io, | |
| 47 | + readonly opts: Values, | |
| 48 | + ) { | |
| 49 | + this.c = colors(io); | |
| 50 | + } | |
| 51 | + | |
| 52 | + get json(): boolean { | |
| 53 | + return this.opts.json === true; | |
| 54 | + } | |
| 55 | + | |
| 56 | + config(): Promise<ResolvedConfig> { | |
| 57 | + return (this.cfg ??= resolveConfig(this.io)); | |
| 58 | + } | |
| 59 | + | |
| 60 | + async api(): Promise<Api> { | |
| 61 | + const cfg = await this.config(); | |
| 62 | + return new Api(cfg.url, cfg.token); | |
| 63 | + } | |
| 64 | + | |
| 65 | + /** Requires a token (for commands that make no sense anonymously). */ | |
| 66 | + async authedApi(): Promise<Api> { | |
| 67 | + const cfg = await this.config(); | |
| 68 | + if (!cfg.token) throw new CliError("not logged in: run `diffinery login` or set DIFFINERY_TOKEN"); | |
| 69 | + return new Api(cfg.url, cfg.token); | |
| 70 | + } | |
| 71 | + | |
| 72 | + out(s: string): void { | |
| 73 | + this.io.stdout.write(s.endsWith("\n") ? s : `${s}\n`); | |
| 74 | + } | |
| 75 | + | |
| 76 | + err(s: string): void { | |
| 77 | + this.io.stderr.write(s.endsWith("\n") ? s : `${s}\n`); | |
| 78 | + } | |
| 79 | + | |
| 80 | + /** -R owner/name, else the current git repository's origin when it points at the configured host. */ | |
| 81 | + async repo(flag?: string): Promise<RepoRef> { | |
| 82 | + const v = flag ?? (typeof this.opts.repo === "string" ? this.opts.repo : undefined); | |
| 83 | + if (v) return parseRepoArg(v); | |
| 84 | + const cfg = await this.config(); | |
| 85 | + const host = new URL(cfg.url).host; | |
| 86 | + const r = await gitCapture(this.io, ["remote", "get-url", "origin"]); | |
| 87 | + if (r.code === 0) { | |
| 88 | + const parsed = parseRemote(r.stdout, host); | |
| 89 | + if (parsed) return parsed; | |
| 90 | + // A remote on another host is often another Diffinery instance (a self-hosted one): say how to switch. | |
| 91 | + throw new CliError(`the origin remote (${r.stdout.trim()}) does not point at ${host}, the Diffinery this CLI uses; pass -R OWNER/NAME, or if it's another Diffinery, sign in there with diffinery login --url https://HOST (or set DIFFINERY_URL)`); | |
| 92 | + } | |
| 93 | + throw new CliError("no repository given: pass -R OWNER/NAME or run inside a clone of a Diffinery repository"); | |
| 94 | + } | |
| 95 | +} | |
| 96 | + | |
| 97 | +export function parseRepoArg(v: string): RepoRef { | |
| 98 | + const s = v.replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "").replace(/\/+$/, ""); | |
| 99 | + const [owner, name, extra] = s.split("/"); | |
| 100 | + if (!owner || !name || extra !== undefined) throw new CliError(`expected OWNER/NAME, got "${v}"`, 2); | |
| 101 | + return { owner, name }; | |
| 102 | +} | |
| 103 | + | |
| 104 | +export const repoPath = (r: RepoRef) => `/repos/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.name)}`; | |
| 105 | + | |
| 106 | +// ---------------------------------------------------------------- help | |
| 107 | + | |
| 108 | +const COMMON: Record<string, OptSpec> = { | |
| 109 | + repo: { type: "string", short: "R", desc: "Repository (default: inferred from the git origin remote)", arg: "OWNER/NAME" }, | |
| 110 | + json: { type: "boolean", desc: "Print raw JSON" }, | |
| 111 | + help: { type: "boolean", short: "h", desc: "Show help" }, | |
| 112 | +}; | |
| 113 | + | |
| 114 | +export function optionsFor(cmd: Command): Record<string, OptSpec> { | |
| 115 | + return { ...(cmd.repo ? { repo: COMMON.repo! } : {}), ...cmd.options, ...(cmd.json ? { json: COMMON.json! } : {}), help: COMMON.help! }; | |
| 116 | +} | |
| 117 | + | |
| 118 | +export function commandHelp(cmd: Command): string { | |
| 119 | + const opts = optionsFor(cmd); | |
| 120 | + const lines = [`${cmd.summary}`, "", `Usage: diffinery ${cmd.name}${cmd.args ? ` ${cmd.args}` : ""}${Object.keys(opts).length > 1 ? " [options]" : ""}`]; | |
| 121 | + if (cmd.description) lines.push("", cmd.description.trim()); | |
| 122 | + const rows = Object.entries(opts).map(([k, o]) => { | |
| 123 | + const flag = `${o.short ? `-${o.short}, ` : " "}--${k}${o.type === "string" ? ` ${o.arg ?? "VALUE"}` : ""}`; | |
| 124 | + return [flag, o.desc + (o.multiple ? " (repeatable)" : "")]; | |
| 125 | + }); | |
| 126 | + const w = Math.max(...rows.map((r) => r[0]!.length)); | |
| 127 | + lines.push("", "Options:", ...rows.map(([f, d]) => ` ${f!.padEnd(w)} ${d}`)); | |
| 128 | + return lines.join("\n") + "\n"; | |
| 129 | +} | |
| 130 | + | |
| 131 | +export function groupHelp(group: string, summary: string, cmds: Command[]): string { | |
| 132 | + const w = Math.max(...cmds.map((c) => c.name.length)); | |
| 133 | + return [summary, "", `Usage: diffinery ${group} <command> [options]`, "", "Commands:", ...cmds.map((c) => ` ${c.name.padEnd(w)} ${c.summary}`), "", `Run 'diffinery ${group} <command> --help' for details.`].join("\n") + "\n"; | |
| 134 | +} | |
| 135 | + | |
| 136 | +// ---------------------------------------------------------------- dispatch | |
| 137 | + | |
| 138 | +export interface Group { | |
| 139 | + name: string; | |
| 140 | + summary: string; | |
| 141 | + commands: Command[]; | |
| 142 | +} | |
| 143 | + | |
| 144 | +export interface Registry { | |
| 145 | + groups: Group[]; | |
| 146 | + commands: Command[]; | |
| 147 | +} | |
| 148 | + | |
| 149 | +export function topHelp(reg: Registry): string { | |
| 150 | + const rows: Array<[string, string]> = [...reg.groups.map((g) => [g.name, g.summary] as [string, string]), ...reg.commands.map((c) => [c.name, c.summary] as [string, string])]; | |
| 151 | + rows.sort((a, b) => a[0].localeCompare(b[0])); | |
| 152 | + const w = Math.max(...rows.map((r) => r[0].length)); | |
| 153 | + return [ | |
| 154 | + `diffinery ${VERSION}: command-line client for Diffinery`, | |
| 155 | + "", | |
| 156 | + "Usage: diffinery <command> [subcommand] [options]", | |
| 157 | + "", | |
| 158 | + "Commands:", | |
| 159 | + ...rows.map(([n, s]) => ` ${n.padEnd(w)} ${s}`), | |
| 160 | + "", | |
| 161 | + "Global:", | |
| 162 | + " --help, -h Show help for any command", | |
| 163 | + " --version Print the version", | |
| 164 | + "", | |
| 165 | + "Configuration: ~/.config/diffinery/config.json (respects XDG_CONFIG_HOME);", | |
| 166 | + "DIFFINERY_URL and DIFFINERY_TOKEN override it.", | |
| 167 | + ].join("\n") + "\n"; | |
| 168 | +} | |
| 169 | + | |
| 170 | +export async function dispatch(reg: Registry, argv: string[], io: Io): Promise<number> { | |
| 171 | + const [first, ...rest] = argv; | |
| 172 | + if (!first || first === "--help" || first === "-h" || first === "help") { | |
| 173 | + if (first === "help" && rest.length) return dispatch(reg, [...rest, "--help"], io); | |
| 174 | + io.stdout.write(topHelp(reg)); | |
| 175 | + return first ? 0 : argv.length ? 0 : 2; | |
| 176 | + } | |
| 177 | + if (first === "--version" || first === "-v" || first === "version") { | |
| 178 | + io.stdout.write(`diffinery ${VERSION}\n`); | |
| 179 | + return 0; | |
| 180 | + } | |
| 181 | + const group = reg.groups.find((g) => g.name === first); | |
| 182 | + if (group) { | |
| 183 | + const [sub, ...args] = rest; | |
| 184 | + if (!sub || sub === "--help" || sub === "-h") { | |
| 185 | + io.stdout.write(groupHelp(group.name, group.summary, group.commands)); | |
| 186 | + return sub ? 0 : 2; | |
| 187 | + } | |
| 188 | + const cmd = group.commands.find((c) => c.name === `${group.name} ${sub}`); | |
| 189 | + if (!cmd) { | |
| 190 | + io.stderr.write(`unknown command "${group.name} ${sub}"\n\n${groupHelp(group.name, group.summary, group.commands)}`); | |
| 191 | + return 2; | |
| 192 | + } | |
| 193 | + return runCommand(cmd, args, io); | |
| 194 | + } | |
| 195 | + const cmd = reg.commands.find((c) => c.name === first); | |
| 196 | + if (!cmd) { | |
| 197 | + io.stderr.write(`unknown command "${first}"; run 'diffinery --help'\n`); | |
| 198 | + return 2; | |
| 199 | + } | |
| 200 | + return runCommand(cmd, rest, io); | |
| 201 | +} | |
| 202 | + | |
| 203 | +export async function runCommand(cmd: Command, argv: string[], io: Io): Promise<number> { | |
| 204 | + const specs = optionsFor(cmd); | |
| 205 | + let values: Values; | |
| 206 | + let positionals: string[]; | |
| 207 | + try { | |
| 208 | + const parsed = parseArgs({ | |
| 209 | + args: argv, | |
| 210 | + options: Object.fromEntries(Object.entries(specs).map(([k, o]) => [k, { type: o.type, ...(o.short ? { short: o.short } : {}), ...(o.multiple ? { multiple: true } : {}) }])), | |
| 211 | + allowPositionals: true, | |
| 212 | + strict: true, | |
| 213 | + }); | |
| 214 | + values = parsed.values as Values; | |
| 215 | + positionals = parsed.positionals; | |
| 216 | + } catch (e) { | |
| 217 | + io.stderr.write(`error: ${(e as Error).message.replace(/^TypeError \[[A-Z_]+\]: /, "")}\n\n${commandHelp(cmd)}`); | |
| 218 | + return 2; | |
| 219 | + } | |
| 220 | + if (values.help) { | |
| 221 | + io.stdout.write(commandHelp(cmd)); | |
| 222 | + return 0; | |
| 223 | + } | |
| 224 | + if ((cmd.minArgs !== undefined && positionals.length < cmd.minArgs) || (cmd.maxArgs !== undefined && positionals.length > cmd.maxArgs)) { | |
| 225 | + io.stderr.write(`error: ${positionals.length < (cmd.minArgs ?? 0) ? "missing arguments" : `unexpected argument "${positionals[cmd.maxArgs ?? 0]}"`}\n\n${commandHelp(cmd)}`); | |
| 226 | + return 2; | |
| 227 | + } | |
| 228 | + const ctx = new Ctx(io, values); | |
| 229 | + try { | |
| 230 | + const code = await cmd.run(ctx, values, positionals); | |
| 231 | + return typeof code === "number" ? code : 0; | |
| 232 | + } catch (e) { | |
| 233 | + if (e instanceof ExitCode) return e.code; | |
| 234 | + if (e instanceof CliError) { | |
| 235 | + io.stderr.write(`${ctx.c.red("error:")} ${e.message}\n`); | |
| 236 | + return e.code; | |
| 237 | + } | |
| 238 | + throw e; | |
| 239 | + } | |
| 240 | +} | |
| 241 | + | |
| 242 | +// ---------------------------------------------------------------- small helpers for commands | |
| 243 | + | |
| 244 | +export const str = (o: Values, k: string): string | undefined => (typeof o[k] === "string" ? (o[k] as string) : undefined); | |
| 245 | +export const list = (o: Values, k: string): string[] => (Array.isArray(o[k]) ? (o[k] as string[]) : typeof o[k] === "string" ? [o[k] as string] : []); | |
| 246 | + | |
| 247 | +export function positiveInt(v: string | undefined, what: string): number { | |
| 248 | + const n = Number(String(v ?? "").replace(/^#/, "")); | |
| 249 | + if (!Number.isInteger(n) || n < 1) throw new CliError(`${what} must be a positive number, got "${v ?? ""}"`, 2); | |
| 250 | + return n; | |
| 251 | +} | |
| 252 | + | |
| 253 | +export function exactlyOne(o: Values, keys: string[], fallback?: string): string { | |
| 254 | + const set = keys.filter((k) => o[k] === true); | |
| 255 | + if (set.length > 1) throw new CliError(`use only one of ${keys.map((k) => `--${k}`).join(", ")}`, 2); | |
| 256 | + if (set.length === 0) { | |
| 257 | + if (fallback) return fallback; | |
| 258 | + throw new CliError(`one of ${keys.map((k) => `--${k}`).join(", ")} is required`, 2); | |
| 259 | + } | |
| 260 | + return set[0]!; | |
| 261 | +} |
addedapps/cli/src/commands/admin.ts+315−0View file
| @@ -1,0 +1,315 @@ | ||
| 1 | +import { readFile } from "node:fs/promises"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { enc } from "../api.js"; | |
| 4 | +import { list, positiveInt, repoPath, str, type Command, type Group } from "../cli.js"; | |
| 5 | +import { CliError, ExitCode, readAll, valueFromFlagOrStdin } from "../io.js"; | |
| 6 | +import { ago, json, table } from "../output.js"; | |
| 7 | + | |
| 8 | +const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; | |
| 9 | +function checkName(name: string, what: string): string { | |
| 10 | + if (!NAME_RE.test(name)) throw new CliError(`invalid ${what} name "${name}": use letters, digits and _, not starting with a digit`, 2); | |
| 11 | + return name; | |
| 12 | +} | |
| 13 | + | |
| 14 | +// ---------------------------------------------------------------- secrets | |
| 15 | + | |
| 16 | +const secretSet: Command = { | |
| 17 | + name: "secret set", | |
| 18 | + summary: "Create or update an Actions secret", | |
| 19 | + description: `The value comes from --body, from stdin when piped, or a hidden prompt. | |
| 20 | +Repository level by default; --org sets an organization secret. An organization secret | |
| 21 | +reaches every repository unless --visibility (or --repos, which implies selected) says otherwise.`, | |
| 22 | + args: "<NAME>", | |
| 23 | + options: { | |
| 24 | + body: { type: "string", short: "b", desc: "Secret value", arg: "VALUE" }, | |
| 25 | + org: { type: "string", short: "o", desc: "Set an organization secret instead", arg: "ORG" }, | |
| 26 | + visibility: { type: "string", desc: "Organization secrets: which repositories get it (all, private or selected)", arg: "all|private|selected" }, | |
| 27 | + repos: { type: "string", desc: "Organization secrets: the repositories that get it, comma separated", arg: "a,b" }, | |
| 28 | + }, | |
| 29 | + repo: true, | |
| 30 | + minArgs: 1, | |
| 31 | + maxArgs: 1, | |
| 32 | + async run(ctx, o, [name]) { | |
| 33 | + checkName(name!, "secret"); | |
| 34 | + const org = str(o, "org"); | |
| 35 | + const target = org ? `/orgs/${enc(org)}/actions/secrets/${enc(name!)}` : `${repoPath(await ctx.repo())}/actions/secrets/${enc(name!)}`; | |
| 36 | + const repos = (str(o, "repos") ?? "").split(",").map((r) => r.trim()).filter(Boolean); | |
| 37 | + const visibility = str(o, "visibility") ?? (repos.length ? "selected" : undefined); | |
| 38 | + if ((visibility || repos.length) && !org) throw new CliError("--visibility and --repos are for organization secrets (--org)", 2); | |
| 39 | + if (visibility && !["all", "private", "selected"].includes(visibility)) throw new CliError("--visibility must be all, private or selected", 2); | |
| 40 | + if (visibility === "selected" && !repos.length) throw new CliError("--visibility selected needs --repos", 2); | |
| 41 | + if (repos.length && visibility !== "selected") throw new CliError("--repos needs --visibility selected", 2); | |
| 42 | + const value = await valueFromFlagOrStdin(ctx.io, str(o, "body"), `Value for ${name}: `); | |
| 43 | + if (!value) throw new CliError("secret value is empty"); | |
| 44 | + const api = await ctx.authedApi(); | |
| 45 | + const ids = await Promise.all(repos.map(async (r) => (await api.get<{ id: number }>(`/repos/${enc(org!)}/${enc(r.replace(/^.*\//, ""))}`)).id)); | |
| 46 | + await api.put(target, { value, ...(visibility ? { visibility } : {}), ...(ids.length ? { selected_repository_ids: ids } : {}) }); | |
| 47 | + ctx.out(`${ctx.c.green("✓")} Set secret ${name}${org ? ` for ${org}` : ""}`); | |
| 48 | + }, | |
| 49 | +}; | |
| 50 | + | |
| 51 | +const secretList: Command = { | |
| 52 | + name: "secret list", | |
| 53 | + summary: "List Actions secret names", | |
| 54 | + options: { org: { type: "string", short: "o", desc: "List organization secrets instead", arg: "ORG" } }, | |
| 55 | + repo: true, | |
| 56 | + json: true, | |
| 57 | + maxArgs: 0, | |
| 58 | + async run(ctx, o) { | |
| 59 | + const org = str(o, "org"); | |
| 60 | + const path = org ? `/orgs/${enc(org)}/actions/secrets` : `${repoPath(await ctx.repo())}/actions/secrets`; | |
| 61 | + const res = await (await ctx.authedApi()).get<{ secrets: Array<{ name: string; updated_at: string }> }>(path); | |
| 62 | + if (ctx.json) return json(ctx.io, res.secrets); | |
| 63 | + if (!res.secrets.length) return ctx.err("No secrets."); | |
| 64 | + ctx.io.stdout.write(table(res.secrets.map((s) => [s.name, ctx.c.dim(`updated ${ago(s.updated_at)}`)]))); | |
| 65 | + }, | |
| 66 | +}; | |
| 67 | + | |
| 68 | +const secretDelete: Command = { | |
| 69 | + name: "secret delete", | |
| 70 | + summary: "Delete an Actions secret", | |
| 71 | + args: "<NAME>", | |
| 72 | + options: { org: { type: "string", short: "o", desc: "Delete an organization secret instead", arg: "ORG" } }, | |
| 73 | + repo: true, | |
| 74 | + minArgs: 1, | |
| 75 | + maxArgs: 1, | |
| 76 | + async run(ctx, o, [name]) { | |
| 77 | + const org = str(o, "org"); | |
| 78 | + await (await ctx.authedApi()).delete(org ? `/orgs/${enc(org)}/actions/secrets/${enc(name!)}` : `${repoPath(await ctx.repo())}/actions/secrets/${enc(name!)}`); | |
| 79 | + ctx.out(`${ctx.c.green("✓")} Deleted secret ${name}`); | |
| 80 | + }, | |
| 81 | +}; | |
| 82 | + | |
| 83 | +const variableSet: Command = { | |
| 84 | + name: "variable set", | |
| 85 | + summary: "Create or update an Actions variable", | |
| 86 | + description: "The value comes from --body, or from stdin when piped.", | |
| 87 | + args: "<NAME>", | |
| 88 | + options: { body: { type: "string", short: "b", desc: "Variable value", arg: "VALUE" } }, | |
| 89 | + repo: true, | |
| 90 | + minArgs: 1, | |
| 91 | + maxArgs: 1, | |
| 92 | + async run(ctx, o, [name]) { | |
| 93 | + checkName(name!, "variable"); | |
| 94 | + let value = str(o, "body"); | |
| 95 | + if (value === undefined) { | |
| 96 | + if (ctx.io.stdin.isTTY) throw new CliError("pass --body VALUE or pipe the value on stdin", 2); | |
| 97 | + value = (await readAll(ctx.io.stdin)).replace(/\r?\n$/, ""); | |
| 98 | + } | |
| 99 | + await (await ctx.authedApi()).put(`${repoPath(await ctx.repo())}/actions/variables/${enc(name!)}`, { value }); | |
| 100 | + ctx.out(`${ctx.c.green("✓")} Set variable ${name}`); | |
| 101 | + }, | |
| 102 | +}; | |
| 103 | + | |
| 104 | +const variableList: Command = { | |
| 105 | + name: "variable list", | |
| 106 | + summary: "List Actions variables", | |
| 107 | + repo: true, | |
| 108 | + json: true, | |
| 109 | + maxArgs: 0, | |
| 110 | + async run(ctx) { | |
| 111 | + const res = await (await ctx.api()).get<{ variables: Array<{ name: string; value: string }> }>(`${repoPath(await ctx.repo())}/actions/variables`); | |
| 112 | + if (ctx.json) return json(ctx.io, res.variables); | |
| 113 | + if (!res.variables.length) return ctx.err("No variables."); | |
| 114 | + ctx.io.stdout.write(table(res.variables.map((v) => [v.name, v.value]))); | |
| 115 | + }, | |
| 116 | +}; | |
| 117 | + | |
| 118 | +// ---------------------------------------------------------------- tokens | |
| 119 | + | |
| 120 | +interface TokenRow { | |
| 121 | + id: string; | |
| 122 | + name: string; | |
| 123 | + prefix: string; | |
| 124 | + scopes: string[]; | |
| 125 | + repoIds: string[] | null; | |
| 126 | + expiresAt: string; | |
| 127 | + lastUsedAt: string | null; | |
| 128 | + createdAt: string; | |
| 129 | +} | |
| 130 | + | |
| 131 | +const tokenCreate: Command = { | |
| 132 | + name: "token create", | |
| 133 | + summary: "Create a personal access token (printed once)", | |
| 134 | + description: "Tokens can't create tokens, so on diffinery.com this needs the web UI (Settings → Access tokens); the command works against a local development server.", | |
| 135 | + options: { | |
| 136 | + name: { type: "string", short: "n", desc: "Token name (required)", arg: "NAME" }, | |
| 137 | + scope: { type: "string", short: "s", multiple: true, desc: "read, write (default) or admin", arg: "SCOPE" }, | |
| 138 | + "expires-days": { type: "string", short: "e", desc: "Days until it expires (1-366, default 90)", arg: "DAYS" }, | |
| 139 | + repo: { type: "string", short: "R", multiple: true, desc: "Restrict the token to this repository", arg: "OWNER/NAME" }, | |
| 140 | + }, | |
| 141 | + json: true, | |
| 142 | + maxArgs: 0, | |
| 143 | + async run(ctx, o) { | |
| 144 | + const name = str(o, "name"); | |
| 145 | + if (!name) throw new CliError("--name is required", 2); | |
| 146 | + const scopes = list(o, "scope").flatMap((s) => s.split(",")).map((s) => s.trim()).filter(Boolean); | |
| 147 | + for (const s of scopes) if (!["read", "write", "admin"].includes(s)) throw new CliError(`unknown scope "${s}" (read, write or admin)`, 2); | |
| 148 | + const days = str(o, "expires-days") ? positiveInt(str(o, "expires-days"), "--expires-days") : undefined; | |
| 149 | + const repos = list(o, "repo"); | |
| 150 | + const t = await (await ctx.authedApi()).post<{ token: string; id: string; expiresAt: string }>("/diffinery/tokens", { name, scopes: scopes.length ? scopes : undefined, expires_in_days: days, repositories: repos.length ? repos : undefined }); | |
| 151 | + if (ctx.json) return json(ctx.io, t); | |
| 152 | + ctx.out(t.token); | |
| 153 | + ctx.err(ctx.c.dim(`Token ${t.id} expires ${t.expiresAt.slice(0, 10)}. It will not be shown again.`)); | |
| 154 | + }, | |
| 155 | +}; | |
| 156 | + | |
| 157 | +const tokenList: Command = { | |
| 158 | + name: "token list", | |
| 159 | + summary: "List your tokens", | |
| 160 | + json: true, | |
| 161 | + maxArgs: 0, | |
| 162 | + async run(ctx) { | |
| 163 | + const rows = await (await ctx.authedApi()).get<TokenRow[]>("/diffinery/tokens"); | |
| 164 | + if (ctx.json) return json(ctx.io, rows); | |
| 165 | + if (!rows.length) return ctx.err("No tokens."); | |
| 166 | + const c = ctx.c; | |
| 167 | + ctx.io.stdout.write( | |
| 168 | + table([ | |
| 169 | + ["ID", "NAME", "PREFIX", "SCOPES", "EXPIRES", "LAST USED"].map((h) => c.dim(h)), | |
| 170 | + ...rows.map((t) => [t.id, t.name, `${t.prefix}…`, t.scopes.join(",") + (t.repoIds ? ` (${t.repoIds.length} repo${t.repoIds.length === 1 ? "" : "s"})` : ""), t.expiresAt.slice(0, 10), t.lastUsedAt ? ago(t.lastUsedAt) : c.dim("never")]), | |
| 171 | + ]), | |
| 172 | + ); | |
| 173 | + }, | |
| 174 | +}; | |
| 175 | + | |
| 176 | +const tokenRevoke: Command = { | |
| 177 | + name: "token revoke", | |
| 178 | + summary: "Revoke a token", | |
| 179 | + args: "<id>", | |
| 180 | + minArgs: 1, | |
| 181 | + maxArgs: 1, | |
| 182 | + async run(ctx, _o, [id]) { | |
| 183 | + await (await ctx.authedApi()).delete(`/diffinery/tokens/${enc(id!)}`); | |
| 184 | + ctx.out(`${ctx.c.green("✓")} Revoked token ${id}`); | |
| 185 | + }, | |
| 186 | +}; | |
| 187 | + | |
| 188 | +// ---------------------------------------------------------------- agents & invites | |
| 189 | + | |
| 190 | +const agentCreate: Command = { | |
| 191 | + name: "agent create", | |
| 192 | + summary: "Create an agent account and print its token once", | |
| 193 | + description: "Creating an agent mints its first token, which tokens can't do: on diffinery.com create agents in the web UI (Settings → Agents); the command works against a local development server.", | |
| 194 | + args: "<login>", | |
| 195 | + options: { | |
| 196 | + org: { type: "string", short: "o", desc: "Owned by this organization instead of you", arg: "ORG" }, | |
| 197 | + name: { type: "string", short: "n", desc: "Display name", arg: "NAME" }, | |
| 198 | + scope: { type: "string", short: "s", multiple: true, desc: "Token scope: read, write (default) or admin", arg: "SCOPE" }, | |
| 199 | + "expires-days": { type: "string", short: "e", desc: "Token lifetime in days (default 90)", arg: "DAYS" }, | |
| 200 | + }, | |
| 201 | + json: true, | |
| 202 | + minArgs: 1, | |
| 203 | + maxArgs: 1, | |
| 204 | + async run(ctx, o, [login]) { | |
| 205 | + const scopes = list(o, "scope"); | |
| 206 | + const days = str(o, "expires-days") ? positiveInt(str(o, "expires-days"), "--expires-days") : undefined; | |
| 207 | + const r = await (await ctx.authedApi()).post<{ agent: { login: string; html_url: string }; token: { token: string; id: string; expiresAt: string } }>("/diffinery/agents", { | |
| 208 | + login, | |
| 209 | + name: str(o, "name"), | |
| 210 | + org: str(o, "org"), | |
| 211 | + token: scopes.length || days ? { scopes: scopes.length ? scopes : undefined, expires_in_days: days } : undefined, | |
| 212 | + }); | |
| 213 | + if (ctx.json) return json(ctx.io, r); | |
| 214 | + ctx.out(`${ctx.c.green("✓")} Created agent ${ctx.c.bold(r.agent.login)}`); | |
| 215 | + ctx.out(r.token.token); | |
| 216 | + ctx.err(ctx.c.dim(`This token (expires ${r.token.expiresAt.slice(0, 10)}) will not be shown again. Give it to the agent as DIFFINERY_TOKEN.`)); | |
| 217 | + }, | |
| 218 | +}; | |
| 219 | + | |
| 220 | +const agentList: Command = { | |
| 221 | + name: "agent list", | |
| 222 | + summary: "List agents you or your organizations own", | |
| 223 | + json: true, | |
| 224 | + maxArgs: 0, | |
| 225 | + async run(ctx) { | |
| 226 | + const rows = await (await ctx.authedApi()).get<Array<{ login: string; name: string | null; owner_id: string; policy: Record<string, unknown> }>>("/diffinery/agents"); | |
| 227 | + if (ctx.json) return json(ctx.io, rows); | |
| 228 | + if (!rows.length) return ctx.err("No agents."); | |
| 229 | + ctx.io.stdout.write(table(rows.map((a) => [ctx.c.bold(a.login), a.name && a.name !== a.login ? a.name : "", ctx.c.dim(typeof a.policy?.maxRole === "string" ? `max role ${a.policy.maxRole}` : "")]))); | |
| 230 | + }, | |
| 231 | +}; | |
| 232 | + | |
| 233 | +const agentDisable: Command = { | |
| 234 | + name: "agent disable", | |
| 235 | + summary: "Disable an agent (its tokens stop working)", | |
| 236 | + args: "<login>", | |
| 237 | + minArgs: 1, | |
| 238 | + maxArgs: 1, | |
| 239 | + async run(ctx, _o, [login]) { | |
| 240 | + await (await ctx.authedApi()).delete(`/diffinery/agents/${enc(login!)}`); | |
| 241 | + ctx.out(`${ctx.c.green("✓")} Disabled agent ${login}`); | |
| 242 | + }, | |
| 243 | +}; | |
| 244 | + | |
| 245 | +const invite: Command = { | |
| 246 | + name: "invite", | |
| 247 | + summary: "Invite someone by email (to an organization, or to the site as an admin)", | |
| 248 | + args: "<email>", | |
| 249 | + options: { | |
| 250 | + org: { type: "string", short: "o", desc: "Organization to join (omit for a site invite; site admins only)", arg: "ORG" }, | |
| 251 | + role: { type: "string", short: "r", desc: "member (default), admin or owner", arg: "ROLE" }, | |
| 252 | + }, | |
| 253 | + json: true, | |
| 254 | + minArgs: 1, | |
| 255 | + maxArgs: 1, | |
| 256 | + async run(ctx, o, [email]) { | |
| 257 | + const org = str(o, "org"); | |
| 258 | + const role = str(o, "role"); | |
| 259 | + if (!org && role) throw new CliError("--role needs --org", 2); | |
| 260 | + if (role && !["member", "admin", "owner"].includes(role)) throw new CliError("--role must be member, admin or owner", 2); | |
| 261 | + const r = await (await ctx.authedApi()).post<{ id: string; url: string }>(org ? `/orgs/${enc(org)}/invitations` : "/invitations", org ? { email, role } : { email }); | |
| 262 | + if (ctx.json) return json(ctx.io, r); | |
| 263 | + ctx.out(`${ctx.c.green("✓")} Invited ${email}${org ? ` to ${org}` : ""}${role ? ` as ${role}` : ""}`); | |
| 264 | + ctx.out(r.url); | |
| 265 | + ctx.err(ctx.c.dim("Send them this link; it is valid for 14 days.")); | |
| 266 | + }, | |
| 267 | +}; | |
| 268 | + | |
| 269 | +// ---------------------------------------------------------------- raw API | |
| 270 | + | |
| 271 | +const apiCmd: Command = { | |
| 272 | + name: "api", | |
| 273 | + summary: "Make an authenticated API request and print the response", | |
| 274 | + args: "<METHOD> <PATH>", | |
| 275 | + description: `PATH is relative to /api/v3 (e.g. /user, /repos/OWNER/NAME/pulls) unless it starts with /api/. | |
| 276 | +--input sends a JSON body from a file, or from stdin with '-'.`, | |
| 277 | + options: { | |
| 278 | + input: { type: "string", short: "i", desc: "Request body file, or '-' for stdin", arg: "FILE" }, | |
| 279 | + field: { type: "string", short: "f", multiple: true, desc: "Add a string field to the JSON body (or query for GET)", arg: "KEY=VALUE" }, | |
| 280 | + }, | |
| 281 | + minArgs: 2, | |
| 282 | + maxArgs: 2, | |
| 283 | + async run(ctx, o, [method, p]) { | |
| 284 | + const m = method!.toUpperCase(); | |
| 285 | + if (!/^[A-Z]+$/.test(m)) throw new CliError(`invalid method "${method}"`, 2); | |
| 286 | + let body: string | undefined; | |
| 287 | + const input = str(o, "input"); | |
| 288 | + if (input) body = input === "-" ? await readAll(ctx.io.stdin) : await readFile(path.resolve(ctx.io.cwd, input), "utf8"); | |
| 289 | + const fields: Record<string, string> = {}; | |
| 290 | + for (const f of list(o, "field")) { | |
| 291 | + const i = f.indexOf("="); | |
| 292 | + if (i <= 0) throw new CliError(`-f expects key=value, got "${f}"`, 2); | |
| 293 | + fields[f.slice(0, i)] = f.slice(i + 1); | |
| 294 | + } | |
| 295 | + const hasFields = Object.keys(fields).length > 0; | |
| 296 | + if (hasFields && m !== "GET" && m !== "HEAD") { | |
| 297 | + if (body) throw new CliError("use either --input or -f, not both", 2); | |
| 298 | + body = JSON.stringify(fields); | |
| 299 | + } | |
| 300 | + const api = await ctx.api(); | |
| 301 | + const res = await api.raw<unknown>(m, p!, { body, query: m === "GET" && hasFields ? fields : undefined, allow: Array.from({ length: 500 }, (_, i) => i + 100) }); | |
| 302 | + const out = typeof res.data === "string" ? res.data : res.data === null ? "" : JSON.stringify(res.data, null, 2); | |
| 303 | + if (out) ctx.out(out); | |
| 304 | + if (res.status >= 400) { | |
| 305 | + ctx.err(ctx.c.red(`HTTP ${res.status}`)); | |
| 306 | + throw new ExitCode(1); | |
| 307 | + } | |
| 308 | + }, | |
| 309 | +}; | |
| 310 | + | |
| 311 | +export const secretGroup: Group = { name: "secret", summary: "Manage Actions secrets", commands: [secretSet, secretList, secretDelete] }; | |
| 312 | +export const variableGroup: Group = { name: "variable", summary: "Manage Actions variables", commands: [variableSet, variableList] }; | |
| 313 | +export const tokenGroup: Group = { name: "token", summary: "Manage personal access tokens", commands: [tokenCreate, tokenList, tokenRevoke] }; | |
| 314 | +export const agentGroup: Group = { name: "agent", summary: "Manage agent accounts", commands: [agentCreate, agentList, agentDisable] }; | |
| 315 | +export const adminCommands: Command[] = [invite, apiCmd]; |
addedapps/cli/src/commands/agent-setup.ts+289−0View file
| @@ -1,0 +1,289 @@ | ||
| 1 | +import { spawn } from "node:child_process"; | |
| 2 | +import { existsSync, statSync } from "node:fs"; | |
| 3 | +import { access, constants } from "node:fs/promises"; | |
| 4 | +import path from "node:path"; | |
| 5 | +import { Api, ApiError, enc } from "../api.js"; | |
| 6 | +import { Ctx, type RepoRef } from "../cli.js"; | |
| 7 | +import { gitCapture, parseRemote } from "../git.js"; | |
| 8 | +import { CliError } from "../io.js"; | |
| 9 | +import { VERSION } from "../version.js"; | |
| 10 | +import { setupClaudePlugins } from "./claude-plugins.js"; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * `diffinery setup --repo OWNER/NAME`: sets up an agent environment (a Claude Code cloud environment, | |
| 14 | + * Codex, a CI runner) for one repository, without a person at the keyboard. What the hosted script | |
| 15 | + * (<server>/<owner>/<repo>/agent-setup.sh, from Settings → Agent setup) runs once it has installed the CLI. | |
| 16 | + * | |
| 17 | + * The token comes from DIFFINERY_TOKEN and is never written to disk: git asks `diffinery auth | |
| 18 | + * git-credential`, which reads it from the environment each time, and gh reads GH_ENTERPRISE_TOKEN. So a | |
| 19 | + * snapshot of the machine (Claude Code caches what a setup script leaves on disk) holds no credential, | |
| 20 | + * and a new token works as soon as the environment variables change. Then a self-test, one line per | |
| 21 | + * check saying what to change when it fails, and a report to the repository's Agent setup page (its Check). | |
| 22 | + */ | |
| 23 | + | |
| 24 | +export interface CheckLine { | |
| 25 | + name: string; | |
| 26 | + ok: boolean; | |
| 27 | + message: string; | |
| 28 | +} | |
| 29 | + | |
| 30 | +interface Proc { | |
| 31 | + code: number; | |
| 32 | + stdout: string; | |
| 33 | + stderr: string; | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** Runs a program and captures its output; a program that isn't installed exits 127. */ | |
| 37 | +export function capture(cmd: string, args: string[], o: { env: NodeJS.ProcessEnv; cwd?: string; input?: string }): Promise<Proc> { | |
| 38 | + return new Promise((resolve) => { | |
| 39 | + let child; | |
| 40 | + try { | |
| 41 | + child = spawn(cmd, args, { env: o.env, cwd: o.cwd, stdio: [o.input === undefined ? "ignore" : "pipe", "pipe", "pipe"] }); | |
| 42 | + } catch (e) { | |
| 43 | + return resolve({ code: 127, stdout: "", stderr: (e as Error).message }); | |
| 44 | + } | |
| 45 | + let stdout = ""; | |
| 46 | + let stderr = ""; | |
| 47 | + child.stdout?.on("data", (d: Buffer) => (stdout += d.toString())); | |
| 48 | + child.stderr?.on("data", (d: Buffer) => (stderr += d.toString())); | |
| 49 | + child.on("error", (e) => resolve({ code: (e as NodeJS.ErrnoException).code === "ENOENT" ? 127 : 1, stdout, stderr: stderr + e.message })); | |
| 50 | + child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr })); | |
| 51 | + if (child.stdin) { | |
| 52 | + child.stdin.on("error", () => {}); | |
| 53 | + child.stdin.end(o.input); | |
| 54 | + } | |
| 55 | + }); | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** The gh token environment variables for a host other than github.com, in gh's order. */ | |
| 59 | +const GH_ENTERPRISE_VARS = ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"] as const; | |
| 60 | + | |
| 61 | +/** Which environment variable gives gh its token for `host`, if any (gh then refuses `gh auth login`). */ | |
| 62 | +export function ghEnvToken(env: NodeJS.ProcessEnv, host: string): { name: string; value: string } | null { | |
| 63 | + if (/^(github\.com|.*\.ghe\.com)$/i.test(host)) return null; | |
| 64 | + for (const name of GH_ENTERPRISE_VARS) if (env[name]) return { name, value: env[name]! }; | |
| 65 | + return null; | |
| 66 | +} | |
| 67 | + | |
| 68 | +/** First line of a command's error output, without anything token-like. */ | |
| 69 | +const firstLine = (s: string) => | |
| 70 | + (s.trim().split("\n").find((l) => l.trim()) ?? "") | |
| 71 | + .replace(/dfy_[A-Za-z0-9]+/g, "dfy_…") | |
| 72 | + .slice(0, 200); | |
| 73 | + | |
| 74 | +/** Where the clone goes: --clone-dir, DIFFINERY_CLONE_DIR, a Claude Code cloud session's /home/user, or here. */ | |
| 75 | +export function cloneParent(env: NodeJS.ProcessEnv, cwd: string, flag?: string): string { | |
| 76 | + const chosen = flag ?? env.DIFFINERY_CLONE_DIR; | |
| 77 | + if (chosen) return path.resolve(cwd, chosen); | |
| 78 | + // Claude Code cloud sessions start in /home/user, but setup scripts run elsewhere. | |
| 79 | + const claude = "/home/user"; | |
| 80 | + const inside = cwd === claude || cwd.startsWith(`${claude}/`); | |
| 81 | + if (!inside && isDir(claude)) return claude; | |
| 82 | + return cwd; | |
| 83 | +} | |
| 84 | + | |
| 85 | +function isDir(p: string): boolean { | |
| 86 | + try { | |
| 87 | + return statSync(p).isDirectory(); | |
| 88 | + } catch { | |
| 89 | + return false; | |
| 90 | + } | |
| 91 | +} | |
| 92 | + | |
| 93 | +/** Whether `dir`, or a directory above it, is a clone of `repo` on `host`. Returns its top level. */ | |
| 94 | +async function cloneRoot(ctx: Ctx, dir: string, host: string, repo: RepoRef): Promise<string | null> { | |
| 95 | + if (!isDir(dir)) return null; | |
| 96 | + const top = await gitCapture(ctx.io, ["rev-parse", "--show-toplevel"], { cwd: dir }); | |
| 97 | + if (top.code !== 0) return null; | |
| 98 | + const url = await gitCapture(ctx.io, ["remote", "get-url", "origin"], { cwd: dir }); | |
| 99 | + const r = url.code === 0 ? parseRemote(url.stdout, host) : null; | |
| 100 | + return r && r.owner.toLowerCase() === repo.owner.toLowerCase() && r.name.toLowerCase() === repo.name.toLowerCase() ? top.stdout.trim() : null; | |
| 101 | +} | |
| 102 | + | |
| 103 | +/** An executable named `name` in one of PATH's directories. */ | |
| 104 | +async function onPath(name: string, pathVar: string | undefined): Promise<string | null> { | |
| 105 | + for (const dir of (pathVar ?? "").split(path.delimiter)) { | |
| 106 | + if (!dir) continue; | |
| 107 | + const file = path.join(dir, name); | |
| 108 | + try { | |
| 109 | + await access(file, constants.X_OK); | |
| 110 | + return file; | |
| 111 | + } catch { | |
| 112 | + /* next */ | |
| 113 | + } | |
| 114 | + } | |
| 115 | + return null; | |
| 116 | +} | |
| 117 | + | |
| 118 | +export interface AgentSetupOptions { | |
| 119 | + repo: RepoRef; | |
| 120 | + url: string; | |
| 121 | + clone: boolean; | |
| 122 | + cloneDir?: string; | |
| 123 | + report: boolean; | |
| 124 | + claudePlugins: boolean; | |
| 125 | +} | |
| 126 | + | |
| 127 | +export async function agentSetup(ctx: Ctx, o: AgentSetupOptions, wireGit: (ctx: Ctx) => Promise<void>): Promise<number> { | |
| 128 | + const { io } = ctx; | |
| 129 | + const env = io.env; | |
| 130 | + const url = o.url; | |
| 131 | + const host = new URL(url).host; | |
| 132 | + const slug = `${o.repo.owner}/${o.repo.name}`; | |
| 133 | + const page = `${url}/${slug}/settings/agent-setup`; | |
| 134 | + const lines: CheckLine[] = []; | |
| 135 | + const line = (name: string, ok: boolean, message: string) => { | |
| 136 | + lines.push({ name, ok, message }); | |
| 137 | + ctx.out(`${ok ? ctx.c.green("✓") : ctx.c.red("✗")} ${name}: ${message}`); | |
| 138 | + }; | |
| 139 | + | |
| 140 | + const cfg = await ctx.config(); | |
| 141 | + const token = cfg.token; | |
| 142 | + | |
| 143 | + // 1. The server. | |
| 144 | + let reachable = false; | |
| 145 | + try { | |
| 146 | + const res = await fetch(`${url}/healthz`, { headers: { "user-agent": `diffinery-cli/${VERSION}` } }); | |
| 147 | + reachable = res.ok; | |
| 148 | + line("server", res.ok, res.ok ? `${host} is reachable` : `${host} answered ${res.status}; try again in a minute`); | |
| 149 | + } catch (e) { | |
| 150 | + const cause = (e as { cause?: { code?: string } }).cause?.code ?? (e as Error).message; | |
| 151 | + line("server", false, `can't reach ${host} (${cause}): allow ${host} in the environment's network settings (Custom with ${host}, or Full)`); | |
| 152 | + } | |
| 153 | + | |
| 154 | + // 2. The diffinery command, for git's credential helper and for the agent. | |
| 155 | + const cmd = await onPath("diffinery", env.DIFFINERY_SETUP_PATH ?? env.PATH); | |
| 156 | + line("diffinery on PATH", !!cmd, cmd ? `${cmd} (${VERSION})` : "not on PATH: run the setup script again, or add ~/.diffinery/bin to PATH"); | |
| 157 | + | |
| 158 | + // 3. The token and what it can do. | |
| 159 | + let login: string | null = null; | |
| 160 | + let canPush = false; | |
| 161 | + if (!token) { | |
| 162 | + line("DIFFINERY_TOKEN", false, `not set: add DIFFINERY_TOKEN=<token> to the environment variables (generate one on ${page})`); | |
| 163 | + } else if (reachable) { | |
| 164 | + const api = new Api(url, token); | |
| 165 | + try { | |
| 166 | + const me = await api.get<{ login: string; type?: string }>("/user"); | |
| 167 | + login = me.login; | |
| 168 | + line("DIFFINERY_TOKEN", true, `acts as ${me.login}${me.type === "Bot" ? " (agent)" : ""}`); | |
| 169 | + } catch (e) { | |
| 170 | + line("DIFFINERY_TOKEN", false, e instanceof ApiError && e.status === 401 ? `refused (revoked or expired): generate a new token on ${page} and paste it into DIFFINERY_TOKEN and GH_ENTERPRISE_TOKEN` : firstLine((e as Error).message)); | |
| 171 | + } | |
| 172 | + if (login) { | |
| 173 | + try { | |
| 174 | + const r = await api.get<{ permissions?: { push?: boolean } }>(`/repos/${enc(o.repo.owner)}/${enc(o.repo.name)}`); | |
| 175 | + canPush = !!r.permissions?.push; | |
| 176 | + line("repository", canPush, canPush ? `${slug} with write access` : `${login} can read ${slug} but not push: use a token from ${page}`); | |
| 177 | + } catch (e) { | |
| 178 | + line("repository", false, e instanceof ApiError && e.status === 404 ? `the token can't see ${slug}: use a token from ${page}` : firstLine((e as Error).message)); | |
| 179 | + } | |
| 180 | + } | |
| 181 | + } | |
| 182 | + | |
| 183 | + // 4. git: the credential helper reads the token from the environment whenever git asks. | |
| 184 | + try { | |
| 185 | + await wireGit(ctx); | |
| 186 | + } catch (e) { | |
| 187 | + line("git", false, firstLine((e as Error).message)); | |
| 188 | + } | |
| 189 | + const remote = `${url}/${slug}.git`; | |
| 190 | + const gitEnv = { ...env, GIT_TERMINAL_PROMPT: "0" }; | |
| 191 | + if (token && reachable && !lines.some((l) => l.name === "git")) { | |
| 192 | + const r = await gitCapture(io, ["ls-remote", "--heads", remote], { env: gitEnv }); | |
| 193 | + line("git", r.code === 0, r.code === 0 ? `git can reach ${remote} with no prompt` : `git ls-remote failed: ${firstLine(r.stderr) || `exit ${r.code}`}`); | |
| 194 | + } | |
| 195 | + | |
| 196 | + // 5. The clone. | |
| 197 | + let clone: string | null = null; | |
| 198 | + if (o.clone && token && reachable) { | |
| 199 | + const parent = cloneParent(env, io.cwd, o.cloneDir); | |
| 200 | + clone = (await cloneRoot(ctx, io.cwd, host, o.repo)) ?? null; | |
| 201 | + const target = clone ?? path.join(parent, o.repo.name); | |
| 202 | + if (!clone && existsSync(target)) { | |
| 203 | + clone = await cloneRoot(ctx, target, host, o.repo); | |
| 204 | + if (!clone) line("clone", false, `${target} exists and isn't a clone of ${slug}: move it, or set DIFFINERY_CLONE_DIR to another directory`); | |
| 205 | + } | |
| 206 | + if (clone) { | |
| 207 | + const f = await gitCapture(io, ["fetch", "--quiet", "origin"], { cwd: clone, env: gitEnv }); | |
| 208 | + let note = "fetched"; | |
| 209 | + if (f.code === 0) { | |
| 210 | + // A clean checkout moves up to its branch on the server, as a fresh clone would be. | |
| 211 | + const dirty = (await gitCapture(io, ["status", "--porcelain"], { cwd: clone })).stdout.trim(); | |
| 212 | + const up = await gitCapture(io, ["rev-parse", "--abbrev-ref", "@{upstream}"], { cwd: clone }); | |
| 213 | + if (!dirty && up.code === 0 && (await gitCapture(io, ["merge", "--ff-only", "--quiet", up.stdout.trim()], { cwd: clone })).code === 0) note = "up to date"; | |
| 214 | + } | |
| 215 | + line("clone", f.code === 0, f.code === 0 ? `${clone} (${note})` : `git fetch failed in ${clone}: ${firstLine(f.stderr)}`); | |
| 216 | + } else if (!lines.some((l) => l.name === "clone")) { | |
| 217 | + const r = await gitCapture(io, ["clone", "--quiet", remote, target], { env: gitEnv }); | |
| 218 | + if (r.code === 0) clone = target; | |
| 219 | + line("clone", r.code === 0, r.code === 0 ? `cloned into ${target}` : `git clone failed: ${firstLine(r.stderr) || `exit ${r.code}`}`); | |
| 220 | + } | |
| 221 | + } | |
| 222 | + | |
| 223 | + // 6. gh: GH_ENTERPRISE_TOKEN in the environment, or gh's own sign-in when it isn't set. | |
| 224 | + const ghVersion = await capture("gh", ["--version"], { env }); | |
| 225 | + if (ghVersion.code !== 0) { | |
| 226 | + line("gh", true, "skipped: the GitHub CLI isn't installed (install it for gh pr and gh api: https://cli.github.com)"); | |
| 227 | + } else if (token && reachable && login) { | |
| 228 | + const fromEnv = ghEnvToken(env, host); | |
| 229 | + let ready = true; | |
| 230 | + if (fromEnv && fromEnv.value !== token) { | |
| 231 | + line("gh", false, `${fromEnv.name} isn't the same token as DIFFINERY_TOKEN: paste the same token into both`); | |
| 232 | + ready = false; | |
| 233 | + } else if (!fromEnv) { | |
| 234 | + // No token for gh in the environment: sign gh in with this one (gh keeps it in its own config). | |
| 235 | + const r = await capture("gh", ["auth", "login", "--hostname", host, "--with-token", "--git-protocol", "https"], { env, input: `${token}\n` }); | |
| 236 | + if (r.code !== 0) { | |
| 237 | + line("gh", false, `gh auth login failed: ${firstLine(r.stderr)}; or add GH_ENTERPRISE_TOKEN=<token> and GH_HOST=${host} to the environment variables`); | |
| 238 | + ready = false; | |
| 239 | + } | |
| 240 | + } | |
| 241 | + if (ready && fromEnv && env.GH_HOST !== host) { | |
| 242 | + line("GH_HOST", false, env.GH_HOST ? `is ${env.GH_HOST}: set GH_HOST=${host} in the environment variables` : `not set: add GH_HOST=${host} to the environment variables`); | |
| 243 | + ready = false; | |
| 244 | + } | |
| 245 | + if (ready) { | |
| 246 | + const r = await capture("gh", ["api", "user", "--hostname", host, "--jq", ".login"], { env }); | |
| 247 | + const who = r.stdout.trim(); | |
| 248 | + line("gh", r.code === 0 && who === login, r.code === 0 ? (who === login ? `gh api acts as ${who}` : `gh acts as ${who}, not ${login}: paste the same token into GH_ENTERPRISE_TOKEN and DIFFINERY_TOKEN`) : `gh api user failed: ${firstLine(r.stderr)}`); | |
| 249 | + if (clone && r.code === 0 && who === login) { | |
| 250 | + const prs = await capture("gh", ["pr", "list", "--state", "all", "--limit", "1", "--json", "number"], { env, cwd: clone }); | |
| 251 | + line("gh in the clone", prs.code === 0, prs.code === 0 ? "gh pr list works in the clone" : `gh pr list failed in ${clone}: ${firstLine(prs.stderr)}`); | |
| 252 | + } | |
| 253 | + } | |
| 254 | + } | |
| 255 | + | |
| 256 | + // 7. Claude Code's Diffinery plugins, where Claude Code runs (a Claude Code cloud environment). | |
| 257 | + if (reachable && o.claudePlugins) { | |
| 258 | + try { | |
| 259 | + // Claude Code cloud sessions start in /home/user (as for the clone above). | |
| 260 | + const p = await setupClaudePlugins(env, url, isDir("/home/user")); | |
| 261 | + if (p) line("Claude Code plugins", p.ok, p.message); | |
| 262 | + } catch (e) { | |
| 263 | + line("Claude Code plugins", false, firstLine((e as Error).message)); | |
| 264 | + } | |
| 265 | + } | |
| 266 | + | |
| 267 | + // 8. The report, which is the Check on the repository's Agent setup page. | |
| 268 | + const ok = lines.every((l) => l.ok); | |
| 269 | + if (o.report && token && reachable && login) { | |
| 270 | + try { | |
| 271 | + await new Api(url, token).post(`/repos/${enc(o.repo.owner)}/${enc(o.repo.name)}/diffinery/agent-setup/checks`, { checks: lines, cli_version: VERSION }); | |
| 272 | + ctx.out(ctx.c.dim(`Reported to ${page}`)); | |
| 273 | + } catch (e) { | |
| 274 | + ctx.err(`${ctx.c.yellow("warning:")} couldn't report the check to ${page}: ${firstLine((e as Error).message)}`); | |
| 275 | + } | |
| 276 | + } | |
| 277 | + const failed = lines.filter((l) => !l.ok).length; | |
| 278 | + ctx.out(""); | |
| 279 | + ctx.out(ok ? `${ctx.c.green("✓")} Ready: git, gh and the diffinery CLI work with ${slug}${clone ? ` (in ${clone})` : ""}.` : `${ctx.c.red("✗")} ${failed} of ${lines.length} checks failed; fix them as each line says, then run this again.`); | |
| 280 | + return ok ? 0 : 1; | |
| 281 | +} | |
| 282 | + | |
| 283 | +/** Parses --repo for setup, which must name the repository explicitly. */ | |
| 284 | +export function setupRepo(v: string): RepoRef { | |
| 285 | + const m = /^([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9._-]+)$/.exec(v.trim().replace(/\.git$/, "")); | |
| 286 | + if (!m) throw new CliError(`--repo expects OWNER/NAME, got "${v}"`, 2); | |
| 287 | + return { owner: m[1]!, name: m[2]! }; | |
| 288 | +} | |
| 289 | + |
addedapps/cli/src/commands/auth.ts+358−0View file
| @@ -1,0 +1,358 @@ | ||
| 1 | +import { Api, ApiError } from "../api.js"; | |
| 2 | +import { Ctx, str, type Command, type Group } from "../cli.js"; | |
| 3 | +import { DEFAULT_URL, normalizeUrl, readConfig, resolveConfig, writeConfig } from "../config.js"; | |
| 4 | +import { spawn } from "node:child_process"; | |
| 5 | +import { gitCapture } from "../git.js"; | |
| 6 | +import { CliError, promptYesNo, readAll } from "../io.js"; | |
| 7 | +import { browserLikely, browserLogin, deviceLogin, revokeCliToken, type Scope } from "../login.js"; | |
| 8 | +import { json } from "../output.js"; | |
| 9 | +import { agentSetup, ghEnvToken, setupRepo } from "./agent-setup.js"; | |
| 10 | +import { setupClaudePlugins } from "./claude-plugins.js"; | |
| 11 | +import { setupCodexMcp } from "./codex.js"; | |
| 12 | + | |
| 13 | +interface User { | |
| 14 | + login: string; | |
| 15 | + name?: string | null; | |
| 16 | + type?: string; | |
| 17 | + email?: string | null; | |
| 18 | + html_url?: string; | |
| 19 | +} | |
| 20 | + | |
| 21 | +const SCOPES = ["read", "write", "admin"] as const; | |
| 22 | + | |
| 23 | +const login: Command = { | |
| 24 | + name: "login", | |
| 25 | + summary: "Sign in through your browser (or with a token)", | |
| 26 | + description: `Opens your browser on the server's authorize page; approve the sign-in there and the CLI | |
| 27 | +gets its own token, valid for 90 days and listed under Settings → Access tokens. | |
| 28 | + | |
| 29 | +On a machine without a browser (over SSH, in a container), --device prints a short code to | |
| 30 | +enter at <server>/login/device from any browser; the CLI picks this by itself when it sees no | |
| 31 | +display. For scripts and CI, pass a token with --token, or pipe one in with --with-token | |
| 32 | +(piping into a plain \`diffinery login\` still works). The config file is written with mode 0600.`, | |
| 33 | + options: { | |
| 34 | + url: { type: "string", desc: "Server URL (default: DIFFINERY_URL, the saved URL, or https://diffinery.com)", arg: "URL" }, | |
| 35 | + scope: { type: "string", desc: "Access to ask for: read, write (default) or admin. You can change it on the authorize page", arg: "SCOPE" }, | |
| 36 | + web: { type: "boolean", desc: "Sign in through the browser on this computer (the default in a terminal)" }, | |
| 37 | + device: { type: "boolean", desc: "Sign in with a code you enter in a browser on any device" }, | |
| 38 | + token: { type: "string", desc: "Use this token (dfy_...) instead of signing in through the browser", arg: "TOKEN" }, | |
| 39 | + "with-token": { type: "boolean", desc: "Read a token from stdin" }, | |
| 40 | + }, | |
| 41 | + maxArgs: 0, | |
| 42 | + async run(ctx, o) { | |
| 43 | + const stored = await readConfig(ctx.io.env); | |
| 44 | + const cfg = await resolveConfig(ctx.io); | |
| 45 | + const url = str(o, "url") ? normalizeUrl(str(o, "url")!) : cfg.url; | |
| 46 | + const scope = (str(o, "scope") ?? "write") as Scope; | |
| 47 | + if (!SCOPES.includes(scope)) throw new CliError(`--scope must be one of ${SCOPES.join(", ")}`, 2); | |
| 48 | + const modes = (["web", "device", "with-token"] as const).filter((k) => o[k] === true); | |
| 49 | + if (modes.length + (str(o, "token") !== undefined ? 1 : 0) > 1) throw new CliError("use only one of --web, --device, --token and --with-token", 2); | |
| 50 | + | |
| 51 | + let token: string; | |
| 52 | + let note = ""; | |
| 53 | + if (str(o, "token") !== undefined || o["with-token"] || (!modes.length && !ctx.io.stdin.isTTY)) { | |
| 54 | + token = (str(o, "token") ?? (await readAll(ctx.io.stdin)).replace(/\r?\n$/, "")).trim(); | |
| 55 | + if (!token) throw new CliError(o["with-token"] || str(o, "token") !== undefined ? "no token given" : "no token on stdin; run `diffinery login` in a terminal to sign in through your browser, or pass --token"); | |
| 56 | + } else { | |
| 57 | + const device = o.device === true || (o.web !== true && !browserLikely(ctx.io)); | |
| 58 | + const grant = device ? await deviceLogin(ctx, url, scope) : await browserLogin(ctx, url, scope); | |
| 59 | + token = grant.access_token; | |
| 60 | + note = ` with ${grant.scope} access until ${new Date(grant.expires_at).toLocaleDateString("en-US", { dateStyle: "medium" })}`; | |
| 61 | + } | |
| 62 | + const me = await new Api(url, token).get<User>("/user"); | |
| 63 | + const file = await writeConfig(ctx.io.env, { ...stored, url, token }); | |
| 64 | + // The token this one replaces: revoke it if a CLI sign-in made it (never a token someone pasted, which may be used elsewhere). | |
| 65 | + if (stored.token && stored.token !== token) await revokeCliToken(stored.url ? normalizeUrl(stored.url) : url, stored.token); | |
| 66 | + ctx.out(`${ctx.c.green("✓")} Logged in to ${url} as ${ctx.c.bold(me.login)}${me.type === "Bot" ? " (agent)" : ""}${note}`); | |
| 67 | + ctx.err(ctx.c.dim(`Saved to ${file}`)); | |
| 68 | + if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is set and overrides the saved token")); | |
| 69 | + }, | |
| 70 | +}; | |
| 71 | + | |
| 72 | +const logout: Command = { | |
| 73 | + name: "logout", | |
| 74 | + summary: "Sign out: forget the saved token, and revoke it if a browser sign-in created it", | |
| 75 | + maxArgs: 0, | |
| 76 | + async run(ctx) { | |
| 77 | + const stored = await readConfig(ctx.io.env); | |
| 78 | + if (!stored.token) ctx.out("Not logged in."); | |
| 79 | + else { | |
| 80 | + const url = stored.url ? normalizeUrl(stored.url) : DEFAULT_URL; | |
| 81 | + const revoked = await revokeCliToken(url, stored.token); | |
| 82 | + await writeConfig(ctx.io.env, { url: stored.url }); | |
| 83 | + ctx.out(`Logged out of ${url}${revoked ? " and revoked the CLI's token" : ""}.`); | |
| 84 | + } | |
| 85 | + if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is still set in your environment")); | |
| 86 | + }, | |
| 87 | +}; | |
| 88 | + | |
| 89 | +const whoami: Command = { | |
| 90 | + name: "whoami", | |
| 91 | + summary: "Show the account the current token acts as", | |
| 92 | + json: true, | |
| 93 | + maxArgs: 0, | |
| 94 | + async run(ctx) { | |
| 95 | + const cfg = await ctx.config(); | |
| 96 | + const me = await (await ctx.authedApi()).get<User>("/user"); | |
| 97 | + if (ctx.json) return json(ctx.io, me); | |
| 98 | + ctx.out(`${ctx.c.bold(me.login)}${me.name && me.name !== me.login ? ` (${me.name})` : ""}${me.type === "Bot" ? " [agent]" : ""}`); | |
| 99 | + ctx.out(ctx.c.dim(`${cfg.url} · token from ${cfg.tokenSource === "env" ? "DIFFINERY_TOKEN" : cfg.file}`)); | |
| 100 | + }, | |
| 101 | +}; | |
| 102 | + | |
| 103 | +const setupGit: Command = { | |
| 104 | + name: "auth setup-git", | |
| 105 | + summary: "Make git use your Diffinery token for HTTPS remotes", | |
| 106 | + description: `Runs: git config --global credential.<url>.helper "!diffinery auth git-credential" | |
| 107 | +(after resetting other helpers for that URL), so clone/fetch/push to the Diffinery host | |
| 108 | +authenticate with the token from the config file or DIFFINERY_TOKEN.`, | |
| 109 | + maxArgs: 0, | |
| 110 | + async run(ctx) { | |
| 111 | + const cfg = await ctx.config(); | |
| 112 | + const key = `credential.${cfg.url}.helper`; | |
| 113 | + await gitCapture(ctx.io, ["config", "--global", "--unset-all", key]); | |
| 114 | + // An empty helper resets the list so a generic helper (e.g. a keychain) does not answer first. | |
| 115 | + for (const value of ["", "!diffinery auth git-credential"]) { | |
| 116 | + const r = await gitCapture(ctx.io, ["config", "--global", "--add", key, value]); | |
| 117 | + if (r.code !== 0) throw new CliError(`git config failed: ${r.stderr.trim()}`); | |
| 118 | + } | |
| 119 | + ctx.out(`${ctx.c.green("✓")} git will use diffinery for ${cfg.url}`); | |
| 120 | + }, | |
| 121 | +}; | |
| 122 | + | |
| 123 | +/** Runs gh with `input` on stdin; output is captured, so a token on stdin never reaches the terminal. */ | |
| 124 | +function ghCapture(env: NodeJS.ProcessEnv, args: string[], input: string): Promise<{ code: number; stderr: string }> { | |
| 125 | + return new Promise((resolve) => { | |
| 126 | + let child; | |
| 127 | + try { | |
| 128 | + child = spawn("gh", args, { env, stdio: ["pipe", "pipe", "pipe"] }); | |
| 129 | + } catch (e) { | |
| 130 | + return resolve({ code: 127, stderr: String((e as Error).message) }); | |
| 131 | + } | |
| 132 | + let stderr = ""; | |
| 133 | + child.stdout.on("data", () => {}); | |
| 134 | + child.stderr.on("data", (d: Buffer) => (stderr += d.toString())); | |
| 135 | + child.on("error", (e) => resolve({ code: 127, stderr: (e as NodeJS.ErrnoException).code === "ENOENT" ? "gh is not installed (https://cli.github.com)" : e.message })); | |
| 136 | + child.on("close", (code) => resolve({ code: code ?? 1, stderr })); | |
| 137 | + child.stdin.on("error", () => {}); | |
| 138 | + child.stdin.end(input); | |
| 139 | + }); | |
| 140 | +} | |
| 141 | + | |
| 142 | +const setupGh: Command = { | |
| 143 | + name: "auth setup-gh", | |
| 144 | + summary: "Sign the GitHub CLI (gh) in to Diffinery with your token", | |
| 145 | + description: `Runs: gh auth login --hostname <host> --with-token --git-protocol https, with the token | |
| 146 | +from the config file or DIFFINERY_TOKEN on its standard input. gh then treats the Diffinery host | |
| 147 | +like GitHub Enterprise Server: gh pr create, view, checks, status, merge and the rest work in | |
| 148 | +clones of Diffinery repositories, and so does the pull request badge of Claude Code, which | |
| 149 | +asks gh for its token.`, | |
| 150 | + maxArgs: 0, | |
| 151 | + async run(ctx) { | |
| 152 | + const cfg = await ctx.config(); | |
| 153 | + if (!cfg.token) throw new CliError("not logged in: run `diffinery login` or set DIFFINERY_TOKEN"); | |
| 154 | + const url = new URL(cfg.url); | |
| 155 | + if (url.protocol !== "https:") throw new CliError(`gh only talks to hosts over HTTPS; ${cfg.url} is ${url.protocol.replace(/:$/, "")}`); | |
| 156 | + // gh refuses to sign in while a token for the host is in its environment, and uses that one instead. | |
| 157 | + const fromEnv = ghEnvToken(ctx.io.env, url.host); | |
| 158 | + if (fromEnv) { | |
| 159 | + if (fromEnv.value !== cfg.token) throw new CliError(`gh uses ${fromEnv.name} from the environment for ${url.host}, which isn't your Diffinery token: set it to the same token, or unset it`); | |
| 160 | + ctx.out(`${ctx.c.green("✓")} gh uses ${fromEnv.name} for ${url.host}${ctx.io.env.GH_HOST === url.host ? "" : ` (set GH_HOST=${url.host} too, so gh finds it in clones)`}`); | |
| 161 | + return; | |
| 162 | + } | |
| 163 | + const r = await ghCapture(ctx.io.env, ["auth", "login", "--hostname", url.host, "--with-token", "--git-protocol", "https"], `${cfg.token}\n`); | |
| 164 | + if (r.code !== 0) throw new CliError(`gh auth login failed: ${r.stderr.trim() || `exit ${r.code}`}`); | |
| 165 | + ctx.out(`${ctx.c.green("✓")} gh is signed in to ${url.host}`); | |
| 166 | + }, | |
| 167 | +}; | |
| 168 | + | |
| 169 | +/** Parses the git credential helper protocol's key=value lines. */ | |
| 170 | +export function parseCredentialInput(text: string): Record<string, string> { | |
| 171 | + const out: Record<string, string> = {}; | |
| 172 | + for (const line of text.split(/\r?\n/)) { | |
| 173 | + if (!line) break; | |
| 174 | + const i = line.indexOf("="); | |
| 175 | + if (i > 0) out[line.slice(0, i)] = line.slice(i + 1); | |
| 176 | + } | |
| 177 | + return out; | |
| 178 | +} | |
| 179 | + | |
| 180 | +const gitCredential: Command = { | |
| 181 | + name: "auth git-credential", | |
| 182 | + summary: "Git credential helper (used by 'auth setup-git')", | |
| 183 | + args: "<get|store|erase>", | |
| 184 | + minArgs: 1, | |
| 185 | + maxArgs: 1, | |
| 186 | + async run(ctx, _o, [op]) { | |
| 187 | + const input = parseCredentialInput(await readAll(ctx.io.stdin)); | |
| 188 | + if (op !== "get") return; // store/erase: the token lives in the config file | |
| 189 | + const cfg = await ctx.config(); | |
| 190 | + if (!cfg.token) return; | |
| 191 | + const want = new URL(cfg.url); | |
| 192 | + const proto = (input.protocol ?? "").toLowerCase(); | |
| 193 | + const host = (input.host ?? "").toLowerCase(); | |
| 194 | + if (proto !== want.protocol.replace(/:$/, "") || host !== want.host.toLowerCase()) return; | |
| 195 | + ctx.io.stdout.write(`protocol=${proto}\nhost=${input.host}\nusername=x-token\npassword=${cfg.token}\n`); | |
| 196 | + }, | |
| 197 | +}; | |
| 198 | + | |
| 199 | +const mcpConfig: Command = { | |
| 200 | + name: "mcp-config", | |
| 201 | + summary: "Print an MCP server config snippet for Claude Code and other MCP clients", | |
| 202 | + description: `By default the token is written as \${DIFFINERY_TOKEN} (expanded by clients such as | |
| 203 | +Claude Code from the environment). Pass --show-token to embed the saved token instead.`, | |
| 204 | + options: { "show-token": { type: "boolean", desc: "Embed the actual token in the snippet" } }, | |
| 205 | + maxArgs: 0, | |
| 206 | + async run(ctx, o) { | |
| 207 | + const cfg = await ctx.config(); | |
| 208 | + let token = "${DIFFINERY_TOKEN}"; | |
| 209 | + if (o["show-token"]) { | |
| 210 | + if (!cfg.token) throw new CliError("not logged in: run `diffinery login` or set DIFFINERY_TOKEN"); | |
| 211 | + token = cfg.token; | |
| 212 | + } | |
| 213 | + json(ctx.io, { mcpServers: { diffinery: { type: "http", url: `${cfg.url}/mcp`, headers: { Authorization: `Bearer ${token}` } } } }); | |
| 214 | + }, | |
| 215 | +}; | |
| 216 | + | |
| 217 | +/** The signed-in account, or null when there is no token or the server turns it down. */ | |
| 218 | +async function signedInAs(url: string, token: string | null): Promise<User | null> { | |
| 219 | + if (!token) return null; | |
| 220 | + try { | |
| 221 | + return await new Api(url, token).get<User>("/user"); | |
| 222 | + } catch (e) { | |
| 223 | + if (e instanceof ApiError && e.status === 401) return null; | |
| 224 | + throw e; | |
| 225 | + } | |
| 226 | +} | |
| 227 | + | |
| 228 | +/** | |
| 229 | + * The last step of `diffinery setup` for a person: offer to turn on auto-merge for the pull requests they open | |
| 230 | + * (Settings → Pull requests), asked on a terminal only, or turned on straight away with --auto-merge. | |
| 231 | + */ | |
| 232 | +async function offerAutoMerge(ctx: Ctx, url: string, o: { yes: boolean; skip: boolean }): Promise<void> { | |
| 233 | + if (o.skip) return; | |
| 234 | + const api = await ctx.authedApi(); | |
| 235 | + const [me, now] = await Promise.all([api.get<User>("/user").catch(() => null), api.get<{ enabled: boolean }>("/diffinery/auto-merge").catch(() => null)]); | |
| 236 | + // An agent's owner sets it for the agent; and a server without the setting has nothing to offer. | |
| 237 | + if (!me || me.type === "Bot" || !now) return; | |
| 238 | + const page = `${url}/settings/pull-requests`; | |
| 239 | + if (now.enabled) return ctx.out(`${ctx.c.green("✓")} Auto-merge turns on by itself for pull requests you open (${page})`); | |
| 240 | + let yes = o.yes; | |
| 241 | + if (!yes) { | |
| 242 | + if (!ctx.io.stdin.isTTY) return; | |
| 243 | + ctx.out(""); | |
| 244 | + ctx.out("Pull requests can merge by themselves once their checks pass: Diffinery turns on auto-merge as you open each one,"); | |
| 245 | + ctx.out("wherever the branch has required checks, and it still waits for every rule of the branch, approvals included."); | |
| 246 | + yes = await promptYesNo(ctx.io, "Turn on auto-merge for pull requests you open? [y/N] "); | |
| 247 | + } | |
| 248 | + if (!yes) return ctx.err(ctx.c.dim(`Auto-merge stays off. Turn it on in ${page}, or for one pull request with diffinery pr create --auto.`)); | |
| 249 | + await api.put("/diffinery/auto-merge", { enabled: true }); | |
| 250 | + ctx.out(`${ctx.c.green("✓")} Auto-merge turns on by itself for pull requests you open (change it in ${page})`); | |
| 251 | +} | |
| 252 | + | |
| 253 | +const setup: Command = { | |
| 254 | + name: "setup", | |
| 255 | + summary: "Set up this computer in one go: sign in, then git and gh", | |
| 256 | + description: `Signs in through your browser (or with a code, on a machine without one) unless the CLI is | |
| 257 | +signed in already, then runs \`diffinery auth setup-git\` so git clone, fetch and push use the | |
| 258 | +sign-in, and \`diffinery auth setup-gh\` when the GitHub CLI (gh) is installed. Where Claude Code is | |
| 259 | +installed, it also adds Claude Code's Diffinery plugins (diffinery and diffinery-ci): they're saved in | |
| 260 | +~/.claude/diffinery-plugins and named in ~/.claude/settings.json, so every new Claude Code session loads | |
| 261 | +them, with their Diffinery URL setting pointed at this server. Skip that with --no-claude-plugins (or | |
| 262 | +DIFFINERY_NO_CLAUDE_PLUGINS=1). For a server other than diffinery.com (--url, a self-hosted Diffinery), it | |
| 263 | +also points Codex's diffinery MCP server there when Codex is installed (codex mcp add; skip with | |
| 264 | +DIFFINERY_NO_CODEX=1). Last, on a terminal, it asks | |
| 265 | +whether to turn on auto-merge for the pull requests you open (--auto-merge says yes without asking, | |
| 266 | +--no-auto-merge skips the question). Safe to run again; it brings the plugins up to date. | |
| 267 | + | |
| 268 | +With --repo OWNER/NAME it sets up an agent environment instead (a Claude Code cloud environment, | |
| 269 | +Codex, a CI runner), with no browser and no prompt: git and gh use the token in DIFFINERY_TOKEN | |
| 270 | +(and GH_ENTERPRISE_TOKEN with GH_HOST for gh) straight from the environment, so no token is written | |
| 271 | +to disk; the repository is cloned unless it's there already; then a self-test prints one line per | |
| 272 | +check, saying what to change when one fails, and reports the result to the repository's Agent setup | |
| 273 | +page. Exits 1 when a check fails. This is what <server>/OWNER/NAME/agent-setup.sh runs.`, | |
| 274 | + options: { | |
| 275 | + url: login.options!.url!, | |
| 276 | + scope: login.options!.scope!, | |
| 277 | + web: login.options!.web!, | |
| 278 | + device: login.options!.device!, | |
| 279 | + repo: { type: "string", desc: "Set up an agent environment for this repository instead, with the token in DIFFINERY_TOKEN (what <server>/OWNER/NAME/agent-setup.sh runs)", arg: "OWNER/NAME" }, | |
| 280 | + "no-clone": { type: "boolean", desc: "With --repo: don't clone the repository (also DIFFINERY_NO_CLONE=1)" }, | |
| 281 | + "clone-dir": { type: "string", desc: "With --repo: where the clone goes (default: DIFFINERY_CLONE_DIR, /home/user in a Claude Code cloud environment, else the current directory)", arg: "DIR" }, | |
| 282 | + "no-report": { type: "boolean", desc: "With --repo: don't report the result to the repository's Agent setup page" }, | |
| 283 | + "no-claude-plugins": { type: "boolean", desc: "Don't add Claude Code's Diffinery plugins (also DIFFINERY_NO_CLAUDE_PLUGINS=1)" }, | |
| 284 | + "auto-merge": { type: "boolean", desc: "Turn on auto-merge for pull requests you open, without asking (it still waits for each branch's required checks and approvals)" }, | |
| 285 | + "no-auto-merge": { type: "boolean", desc: "Don't ask about auto-merge" }, | |
| 286 | + }, | |
| 287 | + maxArgs: 0, | |
| 288 | + async run(ctx, o) { | |
| 289 | + if (str(o, "repo") !== undefined) { | |
| 290 | + // Run by a setup script: never a browser, never a prompt. | |
| 291 | + const cfg = await ctx.config(); | |
| 292 | + const url = str(o, "url") ? normalizeUrl(str(o, "url")!) : cfg.url; | |
| 293 | + let next = ctx; | |
| 294 | + if (url !== cfg.url) { | |
| 295 | + if (cfg.urlSource === "env") throw new CliError(`DIFFINERY_URL is ${cfg.url}, but this sets up ${url}: unset DIFFINERY_URL or set it to ${url}`, 2); | |
| 296 | + // The server is saved (its URL only: a saved token belongs to the other server), so git's credential | |
| 297 | + // helper and later commands use it in sessions that don't set DIFFINERY_URL. | |
| 298 | + await writeConfig(ctx.io.env, { url }); | |
| 299 | + next = new Ctx(ctx.io, ctx.opts); | |
| 300 | + } | |
| 301 | + return agentSetup(next, { repo: setupRepo(str(o, "repo")!), url, clone: o["no-clone"] !== true && !ctx.io.env.DIFFINERY_NO_CLONE, cloneDir: str(o, "clone-dir"), report: o["no-report"] !== true, claudePlugins: o["no-claude-plugins"] !== true }, (c) => setupGit.run(new Ctx(c.io, {}), {}, []) as Promise<void>); | |
| 302 | + } | |
| 303 | + const cfg = await ctx.config(); | |
| 304 | + const url = str(o, "url") ? normalizeUrl(str(o, "url")!) : cfg.url; | |
| 305 | + const me = url === cfg.url ? await signedInAs(url, cfg.token) : null; | |
| 306 | + if (me) ctx.out(`${ctx.c.green("✓")} Signed in to ${url} as ${ctx.c.bold(me.login)}${me.type === "Bot" ? " (agent)" : ""}`); | |
| 307 | + else { | |
| 308 | + // Unlike login, setup never reads a token from stdin: run by an agent or a script, it still signs in a person. | |
| 309 | + const how = o.web === true || o.device === true ? {} : browserLikely(ctx.io) ? { web: true } : { device: true }; | |
| 310 | + await login.run(ctx, { ...o, ...how }, []); | |
| 311 | + } | |
| 312 | + // A fresh context, so the next steps read the sign-in just saved. | |
| 313 | + const next = new Ctx(ctx.io, {}); | |
| 314 | + await setupGit.run(next, {}, []); | |
| 315 | + let failed = false; | |
| 316 | + if (new URL(url).protocol !== "https:") ctx.err(ctx.c.dim("Skipped gh: it only talks to hosts over HTTPS.")); | |
| 317 | + else if ((await ghCapture(ctx.io.env, ["--version"], "")).code !== 0) ctx.err(ctx.c.dim("Skipped gh, which isn't installed. Run `diffinery auth setup-gh` after installing it (https://cli.github.com).")); | |
| 318 | + else | |
| 319 | + try { | |
| 320 | + await setupGh.run(next, {}, []); | |
| 321 | + } catch (e) { | |
| 322 | + if (!(e instanceof CliError)) throw e; | |
| 323 | + ctx.err(`${ctx.c.yellow("warning:")} ${e.message}`); | |
| 324 | + failed = true; | |
| 325 | + } | |
| 326 | + if (o["no-claude-plugins"] !== true) { | |
| 327 | + try { | |
| 328 | + const p = await setupClaudePlugins(ctx.io.env, url); | |
| 329 | + if (p?.ok) ctx.out(`${ctx.c.green("✓")} Claude Code plugins: ${p.message}`); | |
| 330 | + else if (p) { | |
| 331 | + ctx.err(`${ctx.c.yellow("warning:")} Claude Code plugins: ${p.message}`); | |
| 332 | + failed = true; | |
| 333 | + } | |
| 334 | + } catch (e) { | |
| 335 | + ctx.err(`${ctx.c.yellow("warning:")} Claude Code plugins: ${(e as Error).message}`); | |
| 336 | + failed = true; | |
| 337 | + } | |
| 338 | + } | |
| 339 | + try { | |
| 340 | + const p = await setupCodexMcp(ctx.io.env, url); | |
| 341 | + if (p?.ok) ctx.out(`${ctx.c.green("✓")} Codex: ${p.message}`); | |
| 342 | + else if (p) ctx.err(`${ctx.c.yellow("warning:")} Codex: ${p.message}`); | |
| 343 | + } catch (e) { | |
| 344 | + ctx.err(`${ctx.c.yellow("warning:")} Codex: ${(e as Error).message}`); | |
| 345 | + } | |
| 346 | + try { | |
| 347 | + await offerAutoMerge(new Ctx(ctx.io, {}), url, { yes: o["auto-merge"] === true, skip: o["no-auto-merge"] === true }); | |
| 348 | + } catch (e) { | |
| 349 | + ctx.err(`${ctx.c.yellow("warning:")} auto-merge: ${(e as Error).message}`); | |
| 350 | + } | |
| 351 | + ctx.out(""); | |
| 352 | + ctx.out(`Connect Claude Code, Codex, Cursor and other AI apps: ${url}/docs/connect`); | |
| 353 | + return failed ? 1 : 0; | |
| 354 | + }, | |
| 355 | +}; | |
| 356 | + | |
| 357 | +export const authCommands: Command[] = [login, logout, whoami, setup, mcpConfig]; | |
| 358 | +export const authGroup: Group = { name: "auth", summary: "Git and GitHub CLI integration", commands: [setupGit, setupGh, gitCredential] }; |
addedapps/cli/src/commands/claude-plugins.ts+145−0View file
| @@ -1,0 +1,145 @@ | ||
| 1 | +import { createHash } from "node:crypto"; | |
| 2 | +import { existsSync, statSync } from "node:fs"; | |
| 3 | +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; | |
| 4 | +import { homedir } from "node:os"; | |
| 5 | +import path from "node:path"; | |
| 6 | +import { VERSION } from "../version.js"; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Claude Code's Diffinery plugins, set up by `diffinery setup` with nothing else to do: the `diffinery` | |
| 10 | + * plugin (skill, /diffinery:pr-status, MCP server) and `diffinery-ci` (live CI band and pane, and a note | |
| 11 | + * to Claude when CI on a commit it pushed settles). The server serves them at <server>/-/claude/<name>.zip; | |
| 12 | + * they're saved in Claude Code's settings directory and named in the `env` block of its user settings as | |
| 13 | + * CLAUDE_CODE_PLUGIN_DIRS, which Claude Code reads at the start of every session: the terminal, the | |
| 14 | + * desktop app, VS Code, and cloud sessions and project threads, whose setup script runs this. A cloud | |
| 15 | + * environment keeps them in its snapshot. Running it again brings them up to date. Their "Diffinery URL" | |
| 16 | + * setting is set to the server this runs against (pluginConfigs in the same settings file), for these | |
| 17 | + * copies and for copies installed from the Diffinery marketplace alike. | |
| 18 | + */ | |
| 19 | + | |
| 20 | +/** What the plugins' Diffinery URL setting defaults to; `pluginConfigs` is written only to change it. */ | |
| 21 | +const PLUGIN_DEFAULT_URL = "https://diffinery.com"; | |
| 22 | + | |
| 23 | +export const CLAUDE_PLUGINS = ["diffinery", "diffinery-ci"] as const; | |
| 24 | + | |
| 25 | +/** Where Claude Code keeps its user settings: CLAUDE_CONFIG_DIR, else ~/.claude. */ | |
| 26 | +export function claudeDir(env: NodeJS.ProcessEnv): string { | |
| 27 | + return env.CLAUDE_CONFIG_DIR && path.isAbsolute(env.CLAUDE_CONFIG_DIR) ? env.CLAUDE_CONFIG_DIR : path.join(env.HOME || homedir(), ".claude"); | |
| 28 | +} | |
| 29 | + | |
| 30 | +/** Whether Claude Code is on this machine: its settings directory exists, or `claude` is on PATH. */ | |
| 31 | +export function hasClaude(env: NodeJS.ProcessEnv): boolean { | |
| 32 | + if (isDir(claudeDir(env))) return true; | |
| 33 | + return (env.PATH ?? "").split(path.delimiter).some((d) => d && existsSync(path.join(d, "claude"))); | |
| 34 | +} | |
| 35 | + | |
| 36 | +function isDir(p: string): boolean { | |
| 37 | + try { | |
| 38 | + return statSync(p).isDirectory(); | |
| 39 | + } catch { | |
| 40 | + return false; | |
| 41 | + } | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** CLAUDE_CODE_PLUGIN_DIRS with ours in place of any earlier copy of them, others kept in their order. */ | |
| 45 | +export function withOurs(current: string | undefined, ours: string[]): string { | |
| 46 | + const names = new Set(ours.map((p) => path.basename(p))); | |
| 47 | + const kept = (current ?? "") | |
| 48 | + .split(path.delimiter) | |
| 49 | + .map((p) => p.trim()) | |
| 50 | + .filter((p) => p && !(path.basename(path.dirname(p)) === "diffinery-plugins" && names.has(path.basename(p))) && !ours.includes(p)); | |
| 51 | + return [...kept, ...ours].join(path.delimiter); | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** | |
| 55 | + * `pluginConfigs` with each plugin id's `url` option set to `url`, other options and plugins kept; null when | |
| 56 | + * nothing would change. A plugin with no saved URL is left alone for diffinery.com, its default. | |
| 57 | + */ | |
| 58 | +export function withUrlOption(current: unknown, ids: string[], url: string): Record<string, unknown> | null { | |
| 59 | + const all = current && typeof current === "object" && !Array.isArray(current) ? { ...(current as Record<string, unknown>) } : {}; | |
| 60 | + let changed = false; | |
| 61 | + for (const id of ids) { | |
| 62 | + const entry = all[id] && typeof all[id] === "object" && !Array.isArray(all[id]) ? (all[id] as Record<string, unknown>) : {}; | |
| 63 | + const options = entry.options && typeof entry.options === "object" && !Array.isArray(entry.options) ? (entry.options as Record<string, unknown>) : {}; | |
| 64 | + if (options.url === url || (options.url === undefined && url === PLUGIN_DEFAULT_URL)) continue; | |
| 65 | + all[id] = { ...entry, options: { ...options, url } }; | |
| 66 | + changed = true; | |
| 67 | + } | |
| 68 | + return changed ? all : null; | |
| 69 | +} | |
| 70 | + | |
| 71 | +export interface PluginsResult { | |
| 72 | + ok: boolean; | |
| 73 | + message: string; | |
| 74 | +} | |
| 75 | + | |
| 76 | +/** | |
| 77 | + * Downloads the plugins and names them in Claude Code's user settings. Returns the check line's | |
| 78 | + * outcome, or null when there's nothing to do here (no Claude Code, or DIFFINERY_NO_CLAUDE_PLUGINS). | |
| 79 | + * `claudeCloud`: a Claude Code cloud environment, whose setup script may run before Claude Code has | |
| 80 | + * made its settings directory. | |
| 81 | + */ | |
| 82 | +export async function setupClaudePlugins(env: NodeJS.ProcessEnv, url: string, claudeCloud = false): Promise<PluginsResult | null> { | |
| 83 | + if (env.DIFFINERY_NO_CLAUDE_PLUGINS) return null; | |
| 84 | + if (!claudeCloud && !hasClaude(env)) return null; | |
| 85 | + const dir = claudeDir(env); | |
| 86 | + const settingsFile = path.join(dir, "settings.json"); | |
| 87 | + | |
| 88 | + // Read the settings first: a file we can't read as JSON is left as it is. | |
| 89 | + let settings: Record<string, unknown> = {}; | |
| 90 | + try { | |
| 91 | + const text = await readFile(settingsFile, "utf8"); | |
| 92 | + const parsed: unknown = text.trim() ? JSON.parse(text) : {}; | |
| 93 | + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object"); | |
| 94 | + settings = parsed as Record<string, unknown>; | |
| 95 | + } catch (e) { | |
| 96 | + if ((e as NodeJS.ErrnoException).code !== "ENOENT") return { ok: false, message: `${settingsFile} isn't valid JSON, so it was left alone: fix it, then run this again` }; | |
| 97 | + } | |
| 98 | + | |
| 99 | + // A plugin already installed from the Diffinery marketplace (`claude plugin install`) stays as it is. | |
| 100 | + const enabled = settings.enabledPlugins && typeof settings.enabledPlugins === "object" ? Object.keys(settings.enabledPlugins as object) : []; | |
| 101 | + const wanted = CLAUDE_PLUGINS.filter((n) => !enabled.some((k) => k.startsWith(`${n}@`) && (settings.enabledPlugins as Record<string, unknown>)[k] !== false)); | |
| 102 | + const fromMarketplace = CLAUDE_PLUGINS.filter((n) => !wanted.includes(n)); | |
| 103 | + | |
| 104 | + const target = path.join(dir, "diffinery-plugins"); | |
| 105 | + await mkdir(target, { recursive: true }); | |
| 106 | + const got: string[] = []; | |
| 107 | + const files: string[] = []; | |
| 108 | + for (const name of wanted) { | |
| 109 | + let res: Response; | |
| 110 | + try { | |
| 111 | + res = await fetch(`${url}/-/claude/${name}.zip`, { headers: { "user-agent": `diffinery-cli/${VERSION}` } }); | |
| 112 | + } catch (e) { | |
| 113 | + return { ok: false, message: `couldn't download the ${name} plugin: ${(e as Error).message}` }; | |
| 114 | + } | |
| 115 | + if (!res.ok) return { ok: false, message: `couldn't download the ${name} plugin: ${url} answered ${res.status}` }; | |
| 116 | + const body = Buffer.from(await res.arrayBuffer()); | |
| 117 | + const sha = res.headers.get("x-diffinery-plugin-sha256"); | |
| 118 | + if (!sha || createHash("sha256").update(body).digest("hex") !== sha) return { ok: false, message: `the ${name} plugin download didn't match its checksum: run this again` }; | |
| 119 | + const file = path.join(target, `${name}.zip`); | |
| 120 | + await writeFile(`${file}.tmp`, body); | |
| 121 | + await rename(`${file}.tmp`, file); | |
| 122 | + files.push(file); | |
| 123 | + got.push(`${name} ${res.headers.get("x-diffinery-plugin-version") ?? ""}`.trim()); | |
| 124 | + } | |
| 125 | + | |
| 126 | + // Plugins loaded from a directory or zip are <name>@inline; marketplace ones keep their enabledPlugins id. | |
| 127 | + const ids = [...wanted.map((n) => `${n}@inline`), ...enabled.filter((k) => fromMarketplace.some((n) => k.startsWith(`${n}@`)))]; | |
| 128 | + const configs = withUrlOption(settings.pluginConfigs, ids, url); | |
| 129 | + if (files.length || configs) { | |
| 130 | + let next = settings; | |
| 131 | + if (files.length) { | |
| 132 | + const block = settings.env && typeof settings.env === "object" && !Array.isArray(settings.env) ? (settings.env as Record<string, unknown>) : {}; | |
| 133 | + const current = typeof block.CLAUDE_CODE_PLUGIN_DIRS === "string" ? block.CLAUDE_CODE_PLUGIN_DIRS : undefined; | |
| 134 | + next = { ...next, env: { ...block, CLAUDE_CODE_PLUGIN_DIRS: withOurs(current, files) } }; | |
| 135 | + } | |
| 136 | + if (configs) next = { ...next, pluginConfigs: configs }; | |
| 137 | + await writeFile(`${settingsFile}.tmp`, `${JSON.stringify(next, null, 2)}\n`); | |
| 138 | + await rename(`${settingsFile}.tmp`, settingsFile); | |
| 139 | + } | |
| 140 | + const parts = []; | |
| 141 | + if (got.length) parts.push(`${got.join(" and ")} load in new Claude Code sessions (named in ${settingsFile})`); | |
| 142 | + if (fromMarketplace.length) parts.push(`${fromMarketplace.join(" and ")} already installed from the Diffinery marketplace`); | |
| 143 | + if (configs) parts.push(`their Diffinery URL is ${url}`); | |
| 144 | + return { ok: true, message: parts.join("; ") }; | |
| 145 | +} |
addedapps/cli/src/commands/codex.ts+71−0View file
| @@ -1,0 +1,71 @@ | ||
| 1 | +import { spawn } from "node:child_process"; | |
| 2 | +import { appendFile, mkdir, readFile } from "node:fs/promises"; | |
| 3 | +import { homedir } from "node:os"; | |
| 4 | +import path from "node:path"; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Codex's `diffinery` MCP server, pointed at a self-hosted Diffinery by `diffinery setup`. The Codex plugin's | |
| 8 | + * server is https://diffinery.com/mcp (Codex has no plugin settings to change it), and a server of the same | |
| 9 | + * name in Codex's own config replaces it, so for another instance this adds that server to config.toml. | |
| 10 | + * Not with `codex mcp add`, which starts a browser sign-in for a server that offers OAuth and waits for it. | |
| 11 | + * Nothing happens for diffinery.com, or where Codex isn't installed. | |
| 12 | + */ | |
| 13 | + | |
| 14 | +const DEFAULT_URL = "https://diffinery.com"; | |
| 15 | + | |
| 16 | +function codex(env: NodeJS.ProcessEnv, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> { | |
| 17 | + return new Promise((resolve) => { | |
| 18 | + let child; | |
| 19 | + try { | |
| 20 | + child = spawn("codex", args, { env, stdio: ["ignore", "pipe", "pipe"] }); | |
| 21 | + } catch (e) { | |
| 22 | + return resolve({ code: 127, stdout: "", stderr: (e as Error).message }); | |
| 23 | + } | |
| 24 | + let stdout = ""; | |
| 25 | + let stderr = ""; | |
| 26 | + child.stdout.on("data", (d: Buffer) => (stdout += d.toString())); | |
| 27 | + child.stderr.on("data", (d: Buffer) => (stderr += d.toString())); | |
| 28 | + child.on("error", (e) => resolve({ code: 127, stdout, stderr: e.message })); | |
| 29 | + child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr })); | |
| 30 | + }); | |
| 31 | +} | |
| 32 | + | |
| 33 | +export interface CodexResult { | |
| 34 | + ok: boolean; | |
| 35 | + message: string; | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** Null when there's nothing to do: diffinery.com, DIFFINERY_NO_CODEX, or no `codex` on PATH. */ | |
| 39 | +export async function setupCodexMcp(env: NodeJS.ProcessEnv, url: string): Promise<CodexResult | null> { | |
| 40 | + if (url === DEFAULT_URL || env.DIFFINERY_NO_CODEX) return null; | |
| 41 | + const want = `${url}/mcp`; | |
| 42 | + // The server Codex uses now: the user's own, else the plugin's, else none (exit status 1). | |
| 43 | + const got = await codex(env, ["mcp", "get", "diffinery", "--json"]); | |
| 44 | + if (got.code === 127) return null; | |
| 45 | + let bearer: string | null = null; | |
| 46 | + if (got.code === 0) { | |
| 47 | + try { | |
| 48 | + const t = (JSON.parse(got.stdout) as { transport?: { url?: unknown; bearer_token_env_var?: unknown } }).transport ?? {}; | |
| 49 | + if (t.url === want) return { ok: true, message: `the diffinery MCP server is ${want}` }; | |
| 50 | + // A headless setup that sends DIFFINERY_TOKEN keeps doing so. | |
| 51 | + if (typeof t.bearer_token_env_var === "string") bearer = t.bearer_token_env_var; | |
| 52 | + } catch { | |
| 53 | + // An answer we can't read: add the server all the same. | |
| 54 | + } | |
| 55 | + } | |
| 56 | + const home = env.CODEX_HOME && path.isAbsolute(env.CODEX_HOME) ? env.CODEX_HOME : path.join(env.HOME || homedir(), ".codex"); | |
| 57 | + const file = path.join(home, "config.toml"); | |
| 58 | + const text = await readFile(file, "utf8").catch(() => ""); | |
| 59 | + // A server of the user's own goes first (codex removes it however it's written); the plugin's stays. | |
| 60 | + if (/^\s*\[\s*mcp_servers\s*\.\s*"?diffinery"?\s*\]/m.test(text) || /^\s*mcp_servers\s*\.\s*"?diffinery"?\s*[.=]/m.test(text)) { | |
| 61 | + const rm = await codex(env, ["mcp", "remove", "diffinery"]); | |
| 62 | + if (rm.code !== 0) return { ok: false, message: `codex mcp remove diffinery failed: ${(rm.stderr.trim().split("\n")[0] || `exit status ${rm.code}`).slice(0, 200)}` }; | |
| 63 | + } | |
| 64 | + const now = await readFile(file, "utf8").catch(() => ""); | |
| 65 | + const table = `[mcp_servers.diffinery]\nurl = ${JSON.stringify(want)}\n${bearer ? `bearer_token_env_var = ${JSON.stringify(bearer)}\n` : ""}`; | |
| 66 | + await mkdir(home, { recursive: true }); | |
| 67 | + await appendFile(file, `${now && !now.endsWith("\n") ? "\n" : ""}${now ? "\n" : ""}${table}`); | |
| 68 | + const check = await codex(env, ["mcp", "get", "diffinery", "--json"]); | |
| 69 | + if (!check.stdout.includes(JSON.stringify(want).slice(1, -1))) return { ok: false, message: `Codex doesn't read the diffinery server added to ${file}: check that file` }; | |
| 70 | + return { ok: true, message: `the diffinery MCP server is now ${want} (sign in with: codex mcp login diffinery)` }; | |
| 71 | +} |
addedapps/cli/src/commands/issue.ts+131−0View file
| @@ -1,0 +1,131 @@ | ||
| 1 | +import { list, positiveInt, repoPath, str, type Command, type Ctx, type Group } from "../cli.js"; | |
| 2 | +import { CliError } from "../io.js"; | |
| 3 | +import { ago, json, table, truncate } from "../output.js"; | |
| 4 | +import { bodyFrom, bodyOpts, type CommentJson, type IssueJson } from "./pr.js"; | |
| 5 | + | |
| 6 | +const issueState = (c: Ctx["c"], i: IssueJson) => (i.state === "open" ? c.green("open") : c.dim(i.state_reason === "not_planned" ? "not planned" : "closed")); | |
| 7 | + | |
| 8 | +const create: Command = { | |
| 9 | + name: "issue create", | |
| 10 | + summary: "Open an issue", | |
| 11 | + options: { | |
| 12 | + title: { type: "string", short: "t", desc: "Title (required)", arg: "TEXT" }, | |
| 13 | + ...bodyOpts, | |
| 14 | + label: { type: "string", short: "l", multiple: true, desc: "Add a label", arg: "NAME" }, | |
| 15 | + assignee: { type: "string", short: "a", multiple: true, desc: "Assign a login", arg: "LOGIN" }, | |
| 16 | + }, | |
| 17 | + repo: true, | |
| 18 | + json: true, | |
| 19 | + maxArgs: 0, | |
| 20 | + async run(ctx, o) { | |
| 21 | + const title = str(o, "title"); | |
| 22 | + if (!title) throw new CliError("--title is required", 2); | |
| 23 | + const repo = await ctx.repo(); | |
| 24 | + const labels = list(o, "label"); | |
| 25 | + const assignees = list(o, "assignee"); | |
| 26 | + const i = await (await ctx.authedApi()).post<IssueJson>(`${repoPath(repo)}/issues`, { title, body: await bodyFrom(ctx, o), labels: labels.length ? labels : undefined, assignees: assignees.length ? assignees : undefined }); | |
| 27 | + if (ctx.json) return json(ctx.io, i); | |
| 28 | + ctx.out(`${ctx.c.green("✓")} Opened issue #${i.number} ${i.title}`); | |
| 29 | + ctx.out(i.html_url); | |
| 30 | + }, | |
| 31 | +}; | |
| 32 | + | |
| 33 | +const listCmd: Command = { | |
| 34 | + name: "issue list", | |
| 35 | + summary: "List issues", | |
| 36 | + options: { | |
| 37 | + state: { type: "string", short: "s", desc: "open, closed or all (default: open)", arg: "STATE" }, | |
| 38 | + label: { type: "string", short: "l", desc: "Only issues with this label", arg: "NAME" }, | |
| 39 | + author: { type: "string", short: "A", desc: "Only issues opened by this login", arg: "LOGIN" }, | |
| 40 | + search: { type: "string", short: "S", desc: "Search text", arg: "QUERY" }, | |
| 41 | + limit: { type: "string", short: "L", desc: "Maximum number to show (default 30)", arg: "N" }, | |
| 42 | + }, | |
| 43 | + repo: true, | |
| 44 | + json: true, | |
| 45 | + maxArgs: 0, | |
| 46 | + async run(ctx, o) { | |
| 47 | + const repo = await ctx.repo(); | |
| 48 | + const state = str(o, "state") ?? "open"; | |
| 49 | + if (!["open", "closed", "all"].includes(state)) throw new CliError("--state must be open, closed or all", 2); | |
| 50 | + const limit = str(o, "limit") ? positiveInt(str(o, "limit"), "--limit") : 30; | |
| 51 | + const items = await (await ctx.api()).get<IssueJson[]>(`${repoPath(repo)}/issues`, { state, labels: str(o, "label"), creator: str(o, "author"), q: str(o, "search"), per_page: Math.min(100, limit + 20) }); | |
| 52 | + // The issues endpoint can include pull requests (as on GitHub); show only issues. | |
| 53 | + const issues = items.filter((i) => !i.pull_request).slice(0, limit); | |
| 54 | + if (ctx.json) return json(ctx.io, issues); | |
| 55 | + if (!issues.length) return ctx.err(`No ${state === "all" ? "" : `${state} `}issues in ${repo.owner}/${repo.name}.`); | |
| 56 | + const c = ctx.c; | |
| 57 | + ctx.io.stdout.write(table(issues.map((i) => [c.bold(`#${i.number}`), truncate(i.title, 60), issueState(c, i), c.cyan(i.labels.map((l) => l.name).join(", ")), c.dim(i.user.login), c.dim(ago(i.updated_at))]))); | |
| 58 | + }, | |
| 59 | +}; | |
| 60 | + | |
| 61 | +const view: Command = { | |
| 62 | + name: "issue view", | |
| 63 | + summary: "Show an issue and its comments", | |
| 64 | + args: "<number>", | |
| 65 | + repo: true, | |
| 66 | + json: true, | |
| 67 | + minArgs: 1, | |
| 68 | + maxArgs: 1, | |
| 69 | + async run(ctx, _o, [num]) { | |
| 70 | + const n = positiveInt(num, "issue number"); | |
| 71 | + const repo = await ctx.repo(); | |
| 72 | + const api = await ctx.api(); | |
| 73 | + const [i, comments] = await Promise.all([api.get<IssueJson>(`${repoPath(repo)}/issues/${n}`), api.get<CommentJson[]>(`${repoPath(repo)}/issues/${n}/comments`)]); | |
| 74 | + if (ctx.json) return json(ctx.io, { ...i, comment_list: comments }); | |
| 75 | + const c = ctx.c; | |
| 76 | + ctx.out(`${c.bold(i.title)} ${c.dim(`#${i.number}`)}`); | |
| 77 | + ctx.out(`${issueState(c, i)} · opened by ${i.user.login} ${ago(i.created_at)}${i.labels.length ? ` · ${c.cyan(i.labels.map((l) => l.name).join(", "))}` : ""}${i.assignees.length ? ` · assigned to ${i.assignees.map((a) => a.login).join(", ")}` : ""}`); | |
| 78 | + if (i.body?.trim()) ctx.out(`\n${i.body.trim()}`); | |
| 79 | + for (const cm of comments) ctx.out(`\n${c.bold(cm.user.login)} ${c.dim(ago(cm.created_at))}\n${cm.body.trim()}`); | |
| 80 | + ctx.out(`\n${c.dim(i.html_url)}`); | |
| 81 | + }, | |
| 82 | +}; | |
| 83 | + | |
| 84 | +const comment: Command = { | |
| 85 | + name: "issue comment", | |
| 86 | + summary: "Comment on an issue", | |
| 87 | + args: "<number>", | |
| 88 | + options: bodyOpts, | |
| 89 | + repo: true, | |
| 90 | + json: true, | |
| 91 | + minArgs: 1, | |
| 92 | + maxArgs: 1, | |
| 93 | + async run(ctx, o, [num]) { | |
| 94 | + const n = positiveInt(num, "issue number"); | |
| 95 | + const body = await bodyFrom(ctx, o, true); | |
| 96 | + if (!body?.trim()) throw new CliError("--body is required", 2); | |
| 97 | + const repo = await ctx.repo(); | |
| 98 | + const r = await (await ctx.authedApi()).post<CommentJson>(`${repoPath(repo)}/issues/${n}/comments`, { body }); | |
| 99 | + if (ctx.json) return json(ctx.io, r); | |
| 100 | + ctx.out(`${ctx.c.green("✓")} Commented on #${n}`); | |
| 101 | + }, | |
| 102 | +}; | |
| 103 | + | |
| 104 | +function setState(state: "open" | "closed"): Command { | |
| 105 | + return { | |
| 106 | + name: state === "closed" ? "issue close" : "issue reopen", | |
| 107 | + summary: state === "closed" ? "Close an issue" : "Reopen an issue", | |
| 108 | + args: "<number>", | |
| 109 | + options: { | |
| 110 | + ...(state === "closed" ? { reason: { type: "string" as const, short: "r", desc: "completed (default) or not_planned", arg: "REASON" } } : {}), | |
| 111 | + comment: { type: "string", short: "c", desc: "Leave a comment too", arg: "TEXT" }, | |
| 112 | + }, | |
| 113 | + repo: true, | |
| 114 | + json: true, | |
| 115 | + minArgs: 1, | |
| 116 | + maxArgs: 1, | |
| 117 | + async run(ctx, o, [num]) { | |
| 118 | + const n = positiveInt(num, "issue number"); | |
| 119 | + const reason = str(o, "reason")?.replace(/[\s-]+/g, "_"); | |
| 120 | + if (reason && !["completed", "not_planned"].includes(reason)) throw new CliError("--reason must be completed or not_planned", 2); | |
| 121 | + const repo = await ctx.repo(); | |
| 122 | + const api = await ctx.authedApi(); | |
| 123 | + if (str(o, "comment")) await api.post(`${repoPath(repo)}/issues/${n}/comments`, { body: str(o, "comment") }); | |
| 124 | + const i = await api.patch<IssueJson>(`${repoPath(repo)}/issues/${n}`, { state, state_reason: state === "closed" ? (reason ?? "completed") : undefined }); | |
| 125 | + if (ctx.json) return json(ctx.io, i); | |
| 126 | + ctx.out(`${ctx.c.green("✓")} ${state === "closed" ? "Closed" : "Reopened"} #${n} ${i.title}`); | |
| 127 | + }, | |
| 128 | + }; | |
| 129 | +} | |
| 130 | + | |
| 131 | +export const issueGroup: Group = { name: "issue", summary: "Work with issues", commands: [create, listCmd, view, comment, setState("closed"), setState("open")] }; |
addedapps/cli/src/commands/migrate.ts+522−0View file
| @@ -1,0 +1,522 @@ | ||
| 1 | +import { mkdtemp, rm } from "node:fs/promises"; | |
| 2 | +import { tmpdir } from "node:os"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import { ApiError, type Api } from "../api.js"; | |
| 5 | +import { parseRepoArg, repoPath, str, type Command, type Ctx, type Group, type RepoRef, type Values } from "../cli.js"; | |
| 6 | +import { basicAuthHeader, git, gitCapture, withGitConfig } from "../git.js"; | |
| 7 | +import { GitHub, GitHubError } from "../github.js"; | |
| 8 | +import { CliError } from "../io.js"; | |
| 9 | +import type { IssueJson } from "./pr.js"; | |
| 10 | +import type { RepoJson } from "./repo.js"; | |
| 11 | + | |
| 12 | +interface GhRepo { | |
| 13 | + full_name: string; | |
| 14 | + name: string; | |
| 15 | + private: boolean; | |
| 16 | + visibility?: "public" | "private" | "internal"; | |
| 17 | + description: string | null; | |
| 18 | + default_branch: string; | |
| 19 | + clone_url: string; | |
| 20 | + html_url: string; | |
| 21 | +} | |
| 22 | + | |
| 23 | +interface GhUser { | |
| 24 | + login: string; | |
| 25 | +} | |
| 26 | + | |
| 27 | +interface GhIssue { | |
| 28 | + number: number; | |
| 29 | + title: string; | |
| 30 | + body: string | null; | |
| 31 | + state: "open" | "closed"; | |
| 32 | + state_reason?: string | null; | |
| 33 | + user: GhUser | null; | |
| 34 | + labels: Array<{ name: string } | string>; | |
| 35 | + comments: number; | |
| 36 | + created_at: string; | |
| 37 | + pull_request?: unknown; | |
| 38 | +} | |
| 39 | + | |
| 40 | +interface GhPull { | |
| 41 | + number: number; | |
| 42 | + title: string; | |
| 43 | + body: string | null; | |
| 44 | + draft?: boolean; | |
| 45 | + user: GhUser | null; | |
| 46 | + created_at: string; | |
| 47 | + head: { ref: string; repo: { full_name: string } | null }; | |
| 48 | + base: { ref: string }; | |
| 49 | +} | |
| 50 | + | |
| 51 | +interface GhComment { | |
| 52 | + body: string | null; | |
| 53 | + user: GhUser | null; | |
| 54 | + created_at: string; | |
| 55 | +} | |
| 56 | + | |
| 57 | +const day = (iso: string) => iso.slice(0, 10); | |
| 58 | +const who = (u: GhUser | null) => (u ? `@${u.login}` : "a deleted user"); | |
| 59 | + | |
| 60 | +export function attribution(kind: "issue" | "pull request" | "comment", n: number, user: GhUser | null, createdAt: string, htmlBase: string): string { | |
| 61 | + const link = `${htmlBase}/${kind === "pull request" ? "pull" : "issues"}/${n}`; | |
| 62 | + if (kind === "comment") return `> Imported from GitHub: comment by ${who(user)} on ${day(createdAt)}`; | |
| 63 | + return `> Imported from GitHub ${kind} [#${n}](${link}) by ${who(user)} on ${day(createdAt)}`; | |
| 64 | +} | |
| 65 | + | |
| 66 | +const withAttribution = (line: string, body: string | null) => `${line}\n\n${body ?? ""}`.trimEnd(); | |
| 67 | + | |
| 68 | +interface Summary { | |
| 69 | + repo: string; | |
| 70 | + created: boolean; | |
| 71 | + branches: number; | |
| 72 | + tags: number; | |
| 73 | + pushFailures: string[]; | |
| 74 | + defaultBranch: string | null; | |
| 75 | + labels: number; | |
| 76 | + issues: Array<{ from: number; to: number }>; | |
| 77 | + pulls: Array<{ from: number; to: number }>; | |
| 78 | + skippedPulls: Array<{ number: number; reason: string }>; | |
| 79 | + comments: number; | |
| 80 | + variables: string[]; | |
| 81 | + secrets: string[]; | |
| 82 | + warnings: string[]; | |
| 83 | +} | |
| 84 | + | |
| 85 | +async function optional<T>(ctx: Ctx, s: Summary, what: string, f: () => Promise<T>): Promise<T | null> { | |
| 86 | + try { | |
| 87 | + return await f(); | |
| 88 | + } catch (e) { | |
| 89 | + if (e instanceof GitHubError && (e.status === 403 || e.status === 404)) { | |
| 90 | + s.warnings.push(`could not read ${what} from GitHub (${e.status}; the token may lack access)`); | |
| 91 | + ctx.err(`${ctx.c.yellow("warning:")} could not read ${what} from GitHub (${e.status})`); | |
| 92 | + return null; | |
| 93 | + } | |
| 94 | + throw e; | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +async function ensureRepo(ctx: Ctx, api: Api, to: RepoRef, me: string, gh: GhRepo, forcePrivate: boolean): Promise<{ repo: RepoJson; created: boolean }> { | |
| 99 | + const existing = await api.raw<RepoJson>("GET", repoPath(to), { allow: [404] }); | |
| 100 | + if (existing.status !== 404) { | |
| 101 | + ctx.out(`Using existing repository ${to.owner}/${to.name}`); | |
| 102 | + return { repo: existing.data, created: false }; | |
| 103 | + } | |
| 104 | + // Diffinery has only public and private; GitHub "internal" repositories become private. | |
| 105 | + const visibility = !forcePrivate && (gh.visibility === "public" || (gh.visibility === undefined && !gh.private)) ? "public" : "private"; | |
| 106 | + const body = { name: to.name, description: gh.description ?? undefined, visibility }; | |
| 107 | + const repo = await api.post<RepoJson>(to.owner === me ? "/user/repos" : `/orgs/${encodeURIComponent(to.owner)}/repos`, body); | |
| 108 | + ctx.out(`${ctx.c.green("✓")} Created ${repo.full_name} (${repo.visibility})`); | |
| 109 | + return { repo, created: true }; | |
| 110 | +} | |
| 111 | + | |
| 112 | +async function migrateGit(ctx: Ctx, gh: GhRepo, target: string, token: string | null, diffineryUrl: string, s: Summary): Promise<Set<string>> { | |
| 113 | + const dir = await mkdtemp(path.join(tmpdir(), "diffinery-migrate-")); | |
| 114 | + const mirror = path.join(dir, "repo.git"); | |
| 115 | + try { | |
| 116 | + ctx.out(`Cloning ${gh.html_url} …`); | |
| 117 | + const cloneToken = /^https?:\/\//.test(gh.clone_url) ? ghTokenFor(ctx, gh.clone_url) : null; | |
| 118 | + const cloneEnv = withGitConfig(ctx.io.env, cloneToken ? [basicAuthHeader(new URL(gh.clone_url).origin, "x-access-token", cloneToken)] : []); | |
| 119 | + const cl = await gitCapture(ctx.io, ["clone", "--mirror", "--quiet", gh.clone_url, mirror], { env: { ...cloneEnv, GIT_TERMINAL_PROMPT: "0" } }); | |
| 120 | + if (cl.code !== 0) throw new CliError(`git clone --mirror failed: ${cl.stderr.trim()}`); | |
| 121 | + const refs = (await git(ctx.io, ["for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags"], { cwd: mirror })).split("\n").filter(Boolean); | |
| 122 | + const branches = new Set(refs.filter((r) => r.startsWith("refs/heads/")).map((r) => r.slice("refs/heads/".length))); | |
| 123 | + s.branches = branches.size; | |
| 124 | + s.tags = refs.length - branches.size; | |
| 125 | + if (!refs.length) { | |
| 126 | + ctx.out("The GitHub repository is empty; nothing to push."); | |
| 127 | + return branches; | |
| 128 | + } | |
| 129 | + ctx.out(`Pushing ${s.branches} branch${s.branches === 1 ? "" : "es"} and ${s.tags} tag${s.tags === 1 ? "" : "s"} to ${target} …`); | |
| 130 | + const pushEnv = { ...withGitConfig(ctx.io.env, token ? [basicAuthHeader(diffineryUrl, "x-token", token)] : []), GIT_TERMINAL_PROMPT: "0" }; | |
| 131 | + const remote = `${diffineryUrl}/${target}.git`; | |
| 132 | + const all = await gitCapture(ctx.io, ["push", "--quiet", remote, "refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], { cwd: mirror, env: pushEnv }); | |
| 133 | + if (all.code !== 0) { | |
| 134 | + // One big push can hit request size limits or a rejected ref; fall back to ref by ref. | |
| 135 | + ctx.err(`${ctx.c.yellow("warning:")} bulk push failed (${all.stderr.trim().split("\n").pop()}); pushing refs one at a time`); | |
| 136 | + for (const ref of refs) { | |
| 137 | + const r = await gitCapture(ctx.io, ["push", "--quiet", remote, `${ref}:${ref}`], { cwd: mirror, env: pushEnv }); | |
| 138 | + if (r.code !== 0) { | |
| 139 | + s.pushFailures.push(`${ref}: ${r.stderr.trim().split("\n").pop()}`); | |
| 140 | + ctx.err(`${ctx.c.red("X")} ${ref}: ${r.stderr.trim().split("\n").pop()}`); | |
| 141 | + } | |
| 142 | + } | |
| 143 | + } | |
| 144 | + return branches; | |
| 145 | + } finally { | |
| 146 | + await rm(dir, { recursive: true, force: true }); | |
| 147 | + } | |
| 148 | +} | |
| 149 | + | |
| 150 | +async function migrateIssues(ctx: Ctx, gh: GitHub, api: Api, src: string, gr: GhRepo, to: RepoRef, branches: Set<string>, s: Summary): Promise<void> { | |
| 151 | + const base = repoPath(to); | |
| 152 | + const ghPath = `/repos/${src}`; | |
| 153 | + // Labels | |
| 154 | + const labels = (await optional(ctx, s, "labels", () => gh.paginate<{ name: string; color: string; description: string | null }>(`${ghPath}/labels`))) ?? []; | |
| 155 | + for (const l of labels) { | |
| 156 | + try { | |
| 157 | + await api.post(`${base}/labels`, { name: l.name, color: l.color, description: l.description ?? "" }); | |
| 158 | + s.labels++; | |
| 159 | + } catch (e) { | |
| 160 | + s.warnings.push(`label ${l.name}: ${(e as Error).message}`); | |
| 161 | + } | |
| 162 | + } | |
| 163 | + if (labels.length) ctx.out(`${ctx.c.green("✓")} Copied ${s.labels} label${s.labels === 1 ? "" : "s"}`); | |
| 164 | + | |
| 165 | + const openPulls = new Map<number, GhPull>(); | |
| 166 | + for (const p of (await optional(ctx, s, "pull requests", () => gh.paginate<GhPull>(`${ghPath}/pulls`, { state: "open" }))) ?? []) openPulls.set(p.number, p); | |
| 167 | + const items = ((await optional(ctx, s, "issues", () => gh.paginate<GhIssue>(`${ghPath}/issues`, { state: "all", sort: "created", direction: "asc" }))) ?? []).sort((a, b) => a.number - b.number); | |
| 168 | + ctx.out(`Importing ${items.length} issues and pull requests …`); | |
| 169 | + | |
| 170 | + for (const it of items) { | |
| 171 | + let created: IssueJson | null = null; | |
| 172 | + if (it.pull_request) { | |
| 173 | + const p = openPulls.get(it.number); | |
| 174 | + if (it.state !== "open" || !p) { | |
| 175 | + s.skippedPulls.push({ number: it.number, reason: it.state === "open" ? "not found" : "closed or merged" }); | |
| 176 | + continue; | |
| 177 | + } | |
| 178 | + if (p.head.repo?.full_name?.toLowerCase() !== gr.full_name.toLowerCase()) { | |
| 179 | + s.skippedPulls.push({ number: it.number, reason: "head branch is in a fork" }); | |
| 180 | + continue; | |
| 181 | + } | |
| 182 | + if (!branches.has(p.head.ref) || !branches.has(p.base.ref)) { | |
| 183 | + s.skippedPulls.push({ number: it.number, reason: `branch ${branches.has(p.head.ref) ? p.base.ref : p.head.ref} missing` }); | |
| 184 | + continue; | |
| 185 | + } | |
| 186 | + try { | |
| 187 | + created = await api.post<IssueJson>(`${base}/pulls`, { title: p.title, body: withAttribution(attribution("pull request", p.number, p.user, p.created_at, gr.html_url), p.body), head: p.head.ref, base: p.base.ref, draft: p.draft || undefined }); | |
| 188 | + s.pulls.push({ from: it.number, to: created.number }); | |
| 189 | + } catch (e) { | |
| 190 | + s.skippedPulls.push({ number: it.number, reason: (e as Error).message }); | |
| 191 | + continue; | |
| 192 | + } | |
| 193 | + } else { | |
| 194 | + const labelNames = it.labels.map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean); | |
| 195 | + const body = withAttribution(attribution("issue", it.number, it.user, it.created_at, gr.html_url), it.body); | |
| 196 | + try { | |
| 197 | + created = await api.post<IssueJson>(`${base}/issues`, { title: it.title, body, labels: labelNames.length ? labelNames : undefined }); | |
| 198 | + } catch (e) { | |
| 199 | + if (!(e instanceof ApiError) || !labelNames.length) throw e; | |
| 200 | + created = await api.post<IssueJson>(`${base}/issues`, { title: it.title, body }); | |
| 201 | + s.warnings.push(`issue #${it.number}: labels not applied (${e.message})`); | |
| 202 | + } | |
| 203 | + s.issues.push({ from: it.number, to: created.number }); | |
| 204 | + } | |
| 205 | + if (it.comments > 0) { | |
| 206 | + const comments = (await optional(ctx, s, `comments of #${it.number}`, () => gh.paginate<GhComment>(`${ghPath}/issues/${it.number}/comments`))) ?? []; | |
| 207 | + for (const cm of comments) { | |
| 208 | + await api.post(`${base}/issues/${created.number}/comments`, { body: withAttribution(attribution("comment", it.number, cm.user, cm.created_at, gr.html_url), cm.body) }); | |
| 209 | + s.comments++; | |
| 210 | + } | |
| 211 | + } | |
| 212 | + if (it.state === "closed" && !it.pull_request) { | |
| 213 | + await api.patch(`${base}/issues/${created.number}`, { state: "closed", state_reason: it.state_reason === "not_planned" ? "not_planned" : "completed" }); | |
| 214 | + } | |
| 215 | + ctx.out(ctx.c.dim(` GitHub #${it.number} → #${created.number} ${it.title.slice(0, 60)}`)); | |
| 216 | + } | |
| 217 | +} | |
| 218 | + | |
| 219 | +// ---------------------------------------------------------------- the server importer | |
| 220 | + | |
| 221 | +/** The import resource returned by `/api/v3/diffinery/imports` (the fields the CLI shows). */ | |
| 222 | +interface ImportJson { | |
| 223 | + id: string; | |
| 224 | + html_url: string; | |
| 225 | + status: "draft" | "queued" | "running" | "succeeded" | "failed" | "cancelled"; | |
| 226 | + kind: "repository" | "organization"; | |
| 227 | + source: { full_name: string }; | |
| 228 | + target: { full_name: string | null; html_url: string | null }; | |
| 229 | + steps: Array<{ name: string; title: string; status: "pending" | "running" | "done" | "skipped" | "failed"; message: string | null; count: number | null }>; | |
| 230 | + git: { phase: string | null; message: string | null; bytes: number; chunks: number } | null; | |
| 231 | + summary: { secretNames?: string[]; environments?: { secretNames: string[] } } & Record<string, unknown>; | |
| 232 | + warnings: string[]; | |
| 233 | + error: string | null; | |
| 234 | + children?: Array<{ id: string; source: string; target: string; status: string; step: string | null; error: string | null }>; | |
| 235 | +} | |
| 236 | + | |
| 237 | +const DONE = new Set(["succeeded", "failed", "cancelled"]); | |
| 238 | +const POLL_MS = 1500; | |
| 239 | + | |
| 240 | +/** Poll an import to completion, printing each step and Git phase as it changes. */ | |
| 241 | +async function pollImport(ctx: Ctx, api: Api, id: string): Promise<ImportJson> { | |
| 242 | + const seenStep = new Map<string, string>(); | |
| 243 | + let seenGit = ""; | |
| 244 | + let seenChild = new Map<string, string>(); | |
| 245 | + for (let i = 0; ; i++) { | |
| 246 | + const imp = await api.get<ImportJson>(`/diffinery/imports/${encodeURIComponent(id)}`); | |
| 247 | + if (!ctx.json) { | |
| 248 | + for (const s of imp.steps) { | |
| 249 | + const prev = seenStep.get(s.name); | |
| 250 | + if (prev === s.status) continue; | |
| 251 | + seenStep.set(s.name, s.status); | |
| 252 | + if (s.status === "running") ctx.out(ctx.c.dim(` … ${s.title}${s.message ? ` (${s.message})` : ""}`)); | |
| 253 | + else if (s.status === "done") ctx.out(` ${ctx.c.green("✓")} ${s.title}${s.count != null ? ` (${s.count})` : s.message ? ` (${s.message})` : ""}`); | |
| 254 | + else if (s.status === "skipped") ctx.out(ctx.c.dim(` – ${s.title}${s.message ? ` (${s.message})` : ""}`)); | |
| 255 | + else if (s.status === "failed") ctx.out(` ${ctx.c.red("X")} ${s.title}${s.message ? `: ${s.message}` : ""}`); | |
| 256 | + } | |
| 257 | + if (imp.git && imp.git.phase && imp.git.phase !== seenGit) { | |
| 258 | + seenGit = imp.git.phase; | |
| 259 | + ctx.out(ctx.c.dim(` … git: ${imp.git.message ?? imp.git.phase}`)); | |
| 260 | + } | |
| 261 | + for (const ch of imp.children ?? []) { | |
| 262 | + const key = `${ch.status}:${ch.step ?? ""}`; | |
| 263 | + if (seenChild.get(ch.id) === key) continue; | |
| 264 | + seenChild.set(ch.id, key); | |
| 265 | + const mark = ch.status === "succeeded" ? ctx.c.green("✓") : ch.status === "failed" ? ctx.c.red("X") : ch.status === "cancelled" ? ctx.c.dim("–") : ctx.c.dim("…"); | |
| 266 | + ctx.out(` ${mark} ${ch.source} → ${ch.target}${ch.status === "running" && ch.step ? ctx.c.dim(` (${ch.step})`) : ch.error ? ctx.c.red(` ${ch.error}`) : ""}`); | |
| 267 | + } | |
| 268 | + } | |
| 269 | + if (DONE.has(imp.status)) return imp; | |
| 270 | + await ctx.io.sleep(POLL_MS); | |
| 271 | + } | |
| 272 | +} | |
| 273 | + | |
| 274 | +/** | |
| 275 | + * Secret names across the repository and its environments, with the command to set each. | |
| 276 | + * Environment secrets (`<environment>/<NAME>`) have no `secret set` option, so they get the | |
| 277 | + * REST call through `diffinery api` instead. | |
| 278 | + */ | |
| 279 | +function secretLines(ctx: Ctx, imp: ImportJson, target: string): void { | |
| 280 | + const repoNames = imp.summary.secretNames ?? []; | |
| 281 | + const envNames = imp.summary.environments?.secretNames ?? []; | |
| 282 | + const total = repoNames.length + envNames.length; | |
| 283 | + if (!total) return; | |
| 284 | + ctx.out(""); | |
| 285 | + ctx.out(`GitHub does not reveal secret values. Set these ${total} secret${total === 1 ? "" : "s"} by hand:`); | |
| 286 | + for (const n of repoNames) ctx.out(` diffinery secret set ${n} -R ${target}`); | |
| 287 | + for (const n of envNames) { | |
| 288 | + const at = n.lastIndexOf("/"); | |
| 289 | + const [env, name] = [n.slice(0, at), n.slice(at + 1)]; | |
| 290 | + ctx.out(` diffinery api PUT /repos/${target}/environments/${encodeURIComponent(env)}/secrets/${encodeURIComponent(name)} -f value=...`); | |
| 291 | + } | |
| 292 | +} | |
| 293 | + | |
| 294 | +function renderImport(ctx: Ctx, imp: ImportJson): number { | |
| 295 | + if (ctx.json) { | |
| 296 | + ctx.out(JSON.stringify(imp, null, 2)); | |
| 297 | + return imp.status === "succeeded" ? 0 : 1; | |
| 298 | + } | |
| 299 | + const c = ctx.c; | |
| 300 | + const target = imp.target.full_name ?? "—"; | |
| 301 | + ctx.out(""); | |
| 302 | + if (imp.status === "succeeded") ctx.out(c.bold(`Imported ${imp.source.full_name} → ${target}`)); | |
| 303 | + else if (imp.status === "cancelled") ctx.out(c.yellow(`Import of ${imp.source.full_name} was cancelled`)); | |
| 304 | + else ctx.out(c.red(`Import of ${imp.source.full_name} failed${imp.error ? `: ${imp.error}` : ""}`)); | |
| 305 | + for (const w of imp.warnings) ctx.err(`${c.yellow("warning:")} ${w}`); | |
| 306 | + if (imp.status === "succeeded") { | |
| 307 | + secretLines(ctx, imp, target); | |
| 308 | + if (imp.target.html_url) { | |
| 309 | + ctx.out(""); | |
| 310 | + ctx.out(imp.target.html_url); | |
| 311 | + } | |
| 312 | + } | |
| 313 | + return imp.status === "succeeded" ? 0 : 1; | |
| 314 | +} | |
| 315 | + | |
| 316 | +const ghToken = (ctx: Ctx) => ctx.io.env.GH_TOKEN || ctx.io.env.GITHUB_TOKEN || null; | |
| 317 | + | |
| 318 | +/** Server imports send the ambient GitHub token along: say so, and to which server, rather than do it silently. */ | |
| 319 | +async function noteSendingToken(ctx: Ctx, token: string | undefined, what: string): Promise<void> { | |
| 320 | + if (!token) return; | |
| 321 | + ctx.err(ctx.c.yellow(`note: sending ${ctx.io.env.GH_TOKEN ? "GH_TOKEN" : "GITHUB_TOKEN"} to ${(await ctx.config()).url} so it can read ${what} from GitHub during the import`)); | |
| 322 | +} | |
| 323 | +const ghApiUrl = (ctx: Ctx) => ctx.io.env.GITHUB_API_URL || undefined; | |
| 324 | + | |
| 325 | +/** | |
| 326 | + * The GitHub token to send to a given URL, or null. M3: only attach it to github.com (its API/host) | |
| 327 | + * or to the host of an explicitly configured GITHUB_API_URL — never to a host taken from the source | |
| 328 | + * repository argument, so a crafted source cannot capture the token. | |
| 329 | + */ | |
| 330 | +function ghTokenFor(ctx: Ctx, url: string): string | null { | |
| 331 | + const token = ghToken(ctx); | |
| 332 | + if (!token) return null; | |
| 333 | + let host: string; | |
| 334 | + try { | |
| 335 | + host = new URL(url).host.toLowerCase(); | |
| 336 | + } catch { | |
| 337 | + return null; | |
| 338 | + } | |
| 339 | + if (host === "github.com" || host === "www.github.com" || host === "api.github.com") return token; | |
| 340 | + const apiUrl = ctx.io.env.GITHUB_API_URL; | |
| 341 | + if (apiUrl) { | |
| 342 | + try { | |
| 343 | + if (new URL(apiUrl).host.toLowerCase() === host) return token; | |
| 344 | + } catch { | |
| 345 | + // ignore a malformed GITHUB_API_URL | |
| 346 | + } | |
| 347 | + } | |
| 348 | + return null; | |
| 349 | +} | |
| 350 | + | |
| 351 | +/** The server-side single-repository import: POST the request, then follow it to completion. */ | |
| 352 | +async function runServer(ctx: Ctx, o: Values, srcArg: string): Promise<number> { | |
| 353 | + const src = parseRepoArg(srcArg.replace(/^https?:\/\/github\.com\//, "")); | |
| 354 | + const srcName = `${src.owner}/${src.name}`; | |
| 355 | + const api = await ctx.authedApi(); | |
| 356 | + const to = str(o, "to") ? parseRepoArg(str(o, "to")!) : null; | |
| 357 | + let owner = to?.owner; | |
| 358 | + if (!owner) owner = (await api.get<{ login: string }>("/user")).login; | |
| 359 | + const body = { | |
| 360 | + source: srcName, | |
| 361 | + owner, | |
| 362 | + name: to?.name, | |
| 363 | + visibility: o.private === true ? "private" : "keep", | |
| 364 | + github_token: ghToken(ctx) ?? undefined, | |
| 365 | + github_api_url: ghApiUrl(ctx), | |
| 366 | + }; | |
| 367 | + await noteSendingToken(ctx, body.github_token, srcName); | |
| 368 | + if (!ctx.json) ctx.out(`Starting server import of ${srcName} …`); | |
| 369 | + const created = await api.post<ImportJson>("/diffinery/imports", body); | |
| 370 | + const final = await pollImport(ctx, api, created.id); | |
| 371 | + return renderImport(ctx, final); | |
| 372 | +} | |
| 373 | + | |
| 374 | +const github: Command = { | |
| 375 | + name: "migrate github", | |
| 376 | + summary: "Import a GitHub repository (git data, issues, pull requests, releases and more)", | |
| 377 | + args: "<GH-OWNER/GH-REPO>", | |
| 378 | + description: `Runs a full server-side import: git data (keeping issue and pull-request numbers), labels, | |
| 379 | +milestones, issues and pull requests with comments and reviews, branch protection, Actions | |
| 380 | +variables, release assets and deployment environments. The command follows progress until the | |
| 381 | +import finishes. Secret values are never read from GitHub; their names are listed to set by hand. | |
| 382 | + | |
| 383 | +With --local, runs the old client-side import instead (mirror push plus a best-effort recreation | |
| 384 | +of issues and open pull requests); --issues then also copies labels, issues and comments. | |
| 385 | + | |
| 386 | +GitHub access uses GH_TOKEN or GITHUB_TOKEN (needed for private repositories; GITHUB_API_URL | |
| 387 | +selects a GitHub Enterprise API).`, | |
| 388 | + options: { | |
| 389 | + to: { type: "string", short: "t", desc: "Diffinery repository to create (default: <you>/<gh-repo>)", arg: "OWNER/NAME" }, | |
| 390 | + private: { type: "boolean", short: "p", desc: "Create the repository as private regardless of GitHub's visibility" }, | |
| 391 | + local: { type: "boolean", short: "l", desc: "Use the old client-side import (no server importer)" }, | |
| 392 | + issues: { type: "boolean", short: "i", desc: "With --local: also import labels, issues, comments and open pull requests" }, | |
| 393 | + }, | |
| 394 | + json: true, | |
| 395 | + minArgs: 1, | |
| 396 | + maxArgs: 1, | |
| 397 | + async run(ctx, o, [srcArg]) { | |
| 398 | + if (o.local !== true) return runServer(ctx, o, srcArg!); | |
| 399 | + const src = parseRepoArg(srcArg!.replace(/^https?:\/\/github\.com\//, "")); | |
| 400 | + const srcName = `${src.owner}/${src.name}`; | |
| 401 | + const api = await ctx.authedApi(); | |
| 402 | + const cfg = await ctx.config(); | |
| 403 | + const token = ghToken(ctx); | |
| 404 | + const gh = new GitHub({ baseUrl: ctx.io.env.GITHUB_API_URL, token, sleep: (ms) => ctx.io.sleep(ms), log: (m) => ctx.err(ctx.c.yellow(m)) }); | |
| 405 | + if (!token) ctx.err(ctx.c.yellow("note: GH_TOKEN/GITHUB_TOKEN is not set; only public data can be read and GitHub rate limits are low")); | |
| 406 | + | |
| 407 | + const me = await api.get<{ login: string }>("/user"); | |
| 408 | + const gr = await gh.get<GhRepo>(`/repos/${srcName}`); | |
| 409 | + const to = str(o, "to") ? parseRepoArg(str(o, "to")!) : { owner: me.login, name: gr.name }; | |
| 410 | + const target = `${to.owner}/${to.name}`; | |
| 411 | + const s: Summary = { repo: target, created: false, branches: 0, tags: 0, pushFailures: [], defaultBranch: null, labels: 0, issues: [], pulls: [], skippedPulls: [], comments: 0, variables: [], secrets: [], warnings: [] }; | |
| 412 | + | |
| 413 | + const { created } = await ensureRepo(ctx, api, to, me.login, gr, o.private === true); | |
| 414 | + s.created = created; | |
| 415 | + const branches = await migrateGit(ctx, gr, target, cfg.token, cfg.url, s); | |
| 416 | + if (branches.has(gr.default_branch)) { | |
| 417 | + await api.patch(repoPath(to), { default_branch: gr.default_branch }); | |
| 418 | + s.defaultBranch = gr.default_branch; | |
| 419 | + } | |
| 420 | + | |
| 421 | + const vars = (await optional(ctx, s, "Actions variables", () => gh.paginate<{ name: string; value: string }>(`/repos/${srcName}/actions/variables`, {}, "variables"))) ?? []; | |
| 422 | + for (const v of vars) { | |
| 423 | + await api.put(`${repoPath(to)}/actions/variables/${encodeURIComponent(v.name)}`, { value: v.value }); | |
| 424 | + s.variables.push(v.name); | |
| 425 | + } | |
| 426 | + s.secrets = ((await optional(ctx, s, "Actions secret names", () => gh.paginate<{ name: string }>(`/repos/${srcName}/actions/secrets`, {}, "secrets"))) ?? []).map((x) => x.name); | |
| 427 | + | |
| 428 | + if (o.issues) await migrateIssues(ctx, gh, api, srcName, gr, to, branches, s); | |
| 429 | + | |
| 430 | + if (ctx.json) { | |
| 431 | + ctx.out(JSON.stringify(s, null, 2)); | |
| 432 | + return s.pushFailures.length ? 1 : 0; | |
| 433 | + } | |
| 434 | + const c = ctx.c; | |
| 435 | + ctx.out(""); | |
| 436 | + ctx.out(c.bold(`Imported ${srcName} → ${target}`)); | |
| 437 | + ctx.out(` git: ${s.branches} branches, ${s.tags} tags${s.pushFailures.length ? c.red(` (${s.pushFailures.length} refs failed)`) : ""}`); | |
| 438 | + ctx.out(` default: ${s.defaultBranch ?? c.dim("unchanged")}`); | |
| 439 | + ctx.out(` variables: ${s.variables.length}`); | |
| 440 | + if (o.issues) { | |
| 441 | + ctx.out(` labels: ${s.labels}`); | |
| 442 | + ctx.out(` issues: ${s.issues.length} (${s.comments} comments)`); | |
| 443 | + ctx.out(` pulls: ${s.pulls.length} recreated, ${s.skippedPulls.filter((p) => p.reason !== "closed or merged").length} skipped`); | |
| 444 | + for (const p of s.skippedPulls.filter((x) => x.reason !== "closed or merged")) ctx.out(c.dim(` #${p.number}: ${p.reason}`)); | |
| 445 | + } | |
| 446 | + if (s.secrets.length) { | |
| 447 | + ctx.out(""); | |
| 448 | + ctx.out(`GitHub does not reveal secret values. Set these ${s.secrets.length} secret${s.secrets.length === 1 ? "" : "s"}:`); | |
| 449 | + for (const n of s.secrets) ctx.out(` diffinery secret set ${n} -R ${target}`); | |
| 450 | + } | |
| 451 | + for (const w of s.warnings) ctx.err(`${c.yellow("warning:")} ${w}`); | |
| 452 | + ctx.out(""); | |
| 453 | + ctx.out(`${cfg.url}/${target}`); | |
| 454 | + return s.pushFailures.length ? 1 : 0; | |
| 455 | + }, | |
| 456 | +}; | |
| 457 | + | |
| 458 | +const githubOrg: Command = { | |
| 459 | + name: "migrate github-org", | |
| 460 | + summary: "Import many repositories from a GitHub organization or user", | |
| 461 | + args: "<GH-ORG-OR-USER>", | |
| 462 | + description: `Starts a server-side bulk import of every repository in a GitHub organization or user account | |
| 463 | +(or just the ones named with --repos), into a Diffinery organization or user. Each repository is | |
| 464 | +imported in full, the same as "migrate github". The command follows progress until every | |
| 465 | +repository has finished. | |
| 466 | + | |
| 467 | +GitHub access uses GH_TOKEN or GITHUB_TOKEN; GITHUB_API_URL selects a GitHub Enterprise API.`, | |
| 468 | + options: { | |
| 469 | + to: { type: "string", short: "t", desc: "Diffinery owner to import into (default: you)", arg: "OWNER" }, | |
| 470 | + repos: { type: "string", desc: "Comma-separated repository names to import (default: all)", arg: "A,B,C" }, | |
| 471 | + private: { type: "boolean", short: "p", desc: "Create every repository as private" }, | |
| 472 | + "exclude-forks": { type: "boolean", desc: "Skip forks" }, | |
| 473 | + "exclude-archived": { type: "boolean", desc: "Skip archived repositories" }, | |
| 474 | + concurrency: { type: "string", short: "c", desc: "Repositories to import at once, 1–8 (default 3)", arg: "N" }, | |
| 475 | + }, | |
| 476 | + json: true, | |
| 477 | + minArgs: 1, | |
| 478 | + maxArgs: 1, | |
| 479 | + async run(ctx, o, [orgArg]) { | |
| 480 | + const org = orgArg!.replace(/^https?:\/\/github\.com\//, "").replace(/\/+$/, ""); | |
| 481 | + const api = await ctx.authedApi(); | |
| 482 | + const owner = str(o, "to") || (await api.get<{ login: string }>("/user")).login; | |
| 483 | + const repositories = str(o, "repos") | |
| 484 | + ? str(o, "repos")! | |
| 485 | + .split(",") | |
| 486 | + .map((s) => s.trim()) | |
| 487 | + .filter(Boolean) | |
| 488 | + : undefined; | |
| 489 | + const concurrency = str(o, "concurrency") ? Number(str(o, "concurrency")) : undefined; | |
| 490 | + const body = { | |
| 491 | + source_owner: org, | |
| 492 | + owner, | |
| 493 | + repositories, | |
| 494 | + exclude_forks: o["exclude-forks"] === true || undefined, | |
| 495 | + exclude_archived: o["exclude-archived"] === true || undefined, | |
| 496 | + concurrency, | |
| 497 | + visibility: o.private === true ? "private" : "keep", | |
| 498 | + github_token: ghToken(ctx) ?? undefined, | |
| 499 | + github_api_url: ghApiUrl(ctx), | |
| 500 | + }; | |
| 501 | + await noteSendingToken(ctx, body.github_token, org); | |
| 502 | + if (!ctx.json) ctx.out(`Starting bulk import of ${org} into ${owner} …`); | |
| 503 | + const created = await api.post<ImportJson>("/diffinery/imports", body); | |
| 504 | + const final = await pollImport(ctx, api, created.id); | |
| 505 | + if (ctx.json) { | |
| 506 | + ctx.out(JSON.stringify(final, null, 2)); | |
| 507 | + return final.status === "succeeded" ? 0 : 1; | |
| 508 | + } | |
| 509 | + const c = ctx.c; | |
| 510 | + const kids = final.children ?? []; | |
| 511 | + const ok = kids.filter((k) => k.status === "succeeded").length; | |
| 512 | + const failed = kids.filter((k) => k.status === "failed").length; | |
| 513 | + ctx.out(""); | |
| 514 | + ctx.out(c.bold(`Bulk import of ${org}: ${ok}/${kids.length} repositories imported${failed ? c.red(`, ${failed} failed`) : ""}`)); | |
| 515 | + for (const w of final.warnings) ctx.err(`${c.yellow("warning:")} ${w}`); | |
| 516 | + ctx.out(""); | |
| 517 | + ctx.out(final.html_url); | |
| 518 | + return final.status === "succeeded" ? 0 : 1; | |
| 519 | + }, | |
| 520 | +}; | |
| 521 | + | |
| 522 | +export const migrateGroup: Group = { name: "migrate", summary: "Import repositories from other forges", commands: [github, githubOrg] }; |
addedapps/cli/src/commands/pr.ts+469−0View file
| @@ -1,0 +1,469 @@ | ||
| 1 | +import { readFile } from "node:fs/promises"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { exactlyOne, list, positiveInt, repoPath, str, type Command, type Ctx, type Group, type RepoRef, type Values } from "../cli.js"; | |
| 4 | +import { ApiError } from "../api.js"; | |
| 5 | +import { currentBranch, gitCapture } from "../git.js"; | |
| 6 | +import { CliError, ExitCode, readAll } from "../io.js"; | |
| 7 | +import { ago, json, statusText, symbol, table, truncate } from "../output.js"; | |
| 8 | + | |
| 9 | +export interface IssueJson { | |
| 10 | + number: number; | |
| 11 | + title: string; | |
| 12 | + body: string; | |
| 13 | + state: "open" | "closed"; | |
| 14 | + state_reason: string | null; | |
| 15 | + user: { login: string; type: string }; | |
| 16 | + labels: Array<{ name: string }>; | |
| 17 | + assignees: Array<{ login: string }>; | |
| 18 | + comments: number; | |
| 19 | + created_at: string; | |
| 20 | + updated_at: string; | |
| 21 | + closed_at: string | null; | |
| 22 | + html_url: string; | |
| 23 | + pull_request?: { url: string }; | |
| 24 | + head?: { ref: string; sha: string }; | |
| 25 | + base?: { ref: string }; | |
| 26 | + draft?: boolean; | |
| 27 | + merged?: boolean; | |
| 28 | + merged_at?: string | null; | |
| 29 | + merged_by?: { login: string } | null; | |
| 30 | + intent?: string | null; | |
| 31 | +} | |
| 32 | + | |
| 33 | +interface PullDetail extends IssueJson { | |
| 34 | + mergeable: boolean; | |
| 35 | + mergeable_state: string; | |
| 36 | + merge_blockers: string[]; | |
| 37 | + approvals: number; | |
| 38 | + required_approvals: number; | |
| 39 | + checks: Array<{ name: string; state: string }>; | |
| 40 | + auto_merge?: { merge_method: string } | null; | |
| 41 | + /** Only for agents and workflow tokens: what stops Diffinery landing it through auto-merge. */ | |
| 42 | + auto_merge_blockers?: string[]; | |
| 43 | +} | |
| 44 | + | |
| 45 | +export interface CommentJson { | |
| 46 | + id: string; | |
| 47 | + body: string; | |
| 48 | + user: { login: string }; | |
| 49 | + created_at: string; | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** -b/--body, else --body-file (or "-" for stdin), else piped stdin when `stdinFallback`. */ | |
| 53 | +export async function bodyFrom(ctx: Ctx, o: Values, stdinFallback = false): Promise<string | undefined> { | |
| 54 | + const b = str(o, "body"); | |
| 55 | + if (b !== undefined) return b; | |
| 56 | + const f = str(o, "body-file"); | |
| 57 | + if (f !== undefined) { | |
| 58 | + if (f === "-") return readAll(ctx.io.stdin); | |
| 59 | + return readFile(path.resolve(ctx.io.cwd, f), "utf8"); | |
| 60 | + } | |
| 61 | + if (stdinFallback && !ctx.io.stdin.isTTY) return readAll(ctx.io.stdin); | |
| 62 | + return undefined; | |
| 63 | +} | |
| 64 | + | |
| 65 | +export const bodyOpts = { | |
| 66 | + body: { type: "string" as const, short: "b", desc: "Body text (Markdown)", arg: "TEXT" }, | |
| 67 | + "body-file": { type: "string" as const, desc: "Read the body from a file ('-' for stdin)", arg: "FILE" }, | |
| 68 | +}; | |
| 69 | + | |
| 70 | +export function prState(c: Ctx["c"], p: IssueJson): string { | |
| 71 | + if (p.merged) return c.magenta("merged"); | |
| 72 | + if (p.state === "closed") return c.red("closed"); | |
| 73 | + if (p.draft) return c.dim("draft"); | |
| 74 | + return c.green("open"); | |
| 75 | +} | |
| 76 | + | |
| 77 | +const create: Command = { | |
| 78 | + name: "pr create", | |
| 79 | + summary: "Open a pull request", | |
| 80 | + options: { | |
| 81 | + title: { type: "string", short: "t", desc: "Title (required)", arg: "TEXT" }, | |
| 82 | + ...bodyOpts, | |
| 83 | + head: { type: "string", short: "H", desc: "Branch with your changes (default: current branch)", arg: "BRANCH" }, | |
| 84 | + base: { type: "string", short: "B", desc: "Branch to merge into (default: the repository's default branch)", arg: "BRANCH" }, | |
| 85 | + draft: { type: "boolean", short: "d", desc: "Open as a draft" }, | |
| 86 | + intent: { type: "string", desc: "What this change is for, for reviewers (agents should always set it)", arg: "TEXT" }, | |
| 87 | + reviewer: { type: "string", short: "r", multiple: true, desc: "Request a review from this login", arg: "LOGIN" }, | |
| 88 | + auto: { type: "boolean", desc: "Turn on auto-merge as it opens: it merges once the base branch's required checks pass and every rule is met" }, | |
| 89 | + "no-auto": { type: "boolean", desc: "Keep auto-merge off, even if your setting turns it on for pull requests you open" }, | |
| 90 | + }, | |
| 91 | + repo: true, | |
| 92 | + json: true, | |
| 93 | + maxArgs: 0, | |
| 94 | + async run(ctx, o) { | |
| 95 | + const title = str(o, "title"); | |
| 96 | + if (!title) throw new CliError("--title is required", 2); | |
| 97 | + if (o.auto === true && o["no-auto"] === true) throw new CliError("use only one of --auto, --no-auto", 2); | |
| 98 | + const repo = await ctx.repo(); | |
| 99 | + const head = str(o, "head") ?? (await currentBranch(ctx.io)); | |
| 100 | + const reviewers = list(o, "reviewer"); | |
| 101 | + const payload = { | |
| 102 | + title, | |
| 103 | + body: await bodyFrom(ctx, o), | |
| 104 | + head, | |
| 105 | + base: str(o, "base"), | |
| 106 | + draft: o.draft === true ? true : undefined, | |
| 107 | + intent: str(o, "intent"), | |
| 108 | + reviewers: reviewers.length ? reviewers : undefined, | |
| 109 | + // Diffinery's: true turns auto-merge on as it opens, false keeps it off whatever your setting says. | |
| 110 | + auto_merge: o.auto === true ? true : o["no-auto"] === true ? false : undefined, | |
| 111 | + }; | |
| 112 | + const pr = await (await ctx.authedApi()).post<IssueJson & { auto_merge?: { merge_method: string } | null; auto_merge_skipped?: string }>(`${repoPath(repo)}/pulls`, payload); | |
| 113 | + if (ctx.json) return json(ctx.io, pr); | |
| 114 | + ctx.out(`${ctx.c.green("✓")} Opened #${pr.number} ${pr.title} (${pr.head?.ref} → ${pr.base?.ref})`); | |
| 115 | + if (pr.auto_merge) ctx.out(`${ctx.c.dim("auto-merge:")} on (${pr.auto_merge.merge_method}): it merges once every requirement is met`); | |
| 116 | + else if (pr.auto_merge_skipped) ctx.err(`${ctx.c.yellow("warning:")} auto-merge is off: ${pr.auto_merge_skipped}`); | |
| 117 | + ctx.out(pr.html_url); | |
| 118 | + }, | |
| 119 | +}; | |
| 120 | + | |
| 121 | +const listCmd: Command = { | |
| 122 | + name: "pr list", | |
| 123 | + summary: "List pull requests", | |
| 124 | + options: { | |
| 125 | + state: { type: "string", short: "s", desc: "open, closed, merged or all (default: open)", arg: "STATE" }, | |
| 126 | + limit: { type: "string", short: "L", desc: "Maximum number to show (default 30)", arg: "N" }, | |
| 127 | + author: { type: "string", short: "A", desc: "Only pull requests opened by this login", arg: "LOGIN" }, | |
| 128 | + head: { type: "string", short: "H", desc: "Only pull requests from this branch", arg: "BRANCH" }, | |
| 129 | + }, | |
| 130 | + repo: true, | |
| 131 | + json: true, | |
| 132 | + maxArgs: 0, | |
| 133 | + async run(ctx, o) { | |
| 134 | + const repo = await ctx.repo(); | |
| 135 | + const state = str(o, "state") ?? "open"; | |
| 136 | + if (!["open", "closed", "merged", "all"].includes(state)) throw new CliError("--state must be open, closed, merged or all", 2); | |
| 137 | + const limit = str(o, "limit") ? positiveInt(str(o, "limit"), "--limit") : 30; | |
| 138 | + const head = str(o, "head"); | |
| 139 | + const api = await ctx.api(); | |
| 140 | + let prs = await api.get<IssueJson[]>(`${repoPath(repo)}/pulls`, { | |
| 141 | + state: state === "merged" ? "closed" : state, | |
| 142 | + per_page: Math.min(100, state === "merged" ? 100 : limit), | |
| 143 | + creator: str(o, "author"), | |
| 144 | + head: head ? headQuery(repo, head) : undefined, | |
| 145 | + }); | |
| 146 | + if (state === "merged") prs = prs.filter((p) => p.merged); | |
| 147 | + prs = prs.slice(0, limit); | |
| 148 | + if (ctx.json) return json(ctx.io, prs); | |
| 149 | + if (!prs.length) return ctx.err(`No ${state === "all" ? "" : `${state} `}pull requests ${head ? `from ${head} ` : ""}in ${repo.owner}/${repo.name}.`); | |
| 150 | + const c = ctx.c; | |
| 151 | + ctx.io.stdout.write(table(prs.map((p) => [c.bold(`#${p.number}`), truncate(p.title, 60), c.cyan(`${p.head?.ref ?? ""}`), prState(c, p), c.dim(p.user.login), c.dim(ago(p.updated_at))]))); | |
| 152 | + }, | |
| 153 | +}; | |
| 154 | + | |
| 155 | +/** GitHub's `head` filter form, OWNER:BRANCH (Diffinery also accepts a bare branch). */ | |
| 156 | +const headQuery = (repo: RepoRef, branch: string) => (branch.includes(":") ? branch : `${repo.owner}:${branch}`); | |
| 157 | + | |
| 158 | +/** | |
| 159 | + * The pull request number argument; without one, like gh, the open pull request whose head is | |
| 160 | + * the current git branch. | |
| 161 | + */ | |
| 162 | +async function prNumber(ctx: Ctx, num: string | undefined): Promise<number> { | |
| 163 | + if (num !== undefined) return positiveInt(num, "pull request number"); | |
| 164 | + const r = await gitCapture(ctx.io, ["symbolic-ref", "--quiet", "--short", "HEAD"]); | |
| 165 | + const branch = r.code === 0 ? r.stdout.trim() : ""; | |
| 166 | + if (!branch) throw new CliError("no pull request number given, and there is no current branch to find one for (detached HEAD or not a git repository); pass a pull request number"); | |
| 167 | + const repo = await ctx.repo(); | |
| 168 | + const prs = await (await ctx.api()).get<IssueJson[]>(`${repoPath(repo)}/pulls`, { head: headQuery(repo, branch), state: "open" }); | |
| 169 | + const pr = prs.find((p) => p.head?.ref === branch); | |
| 170 | + if (!pr) throw new CliError(`no open pull request for the current branch (${branch}) in ${repo.owner}/${repo.name}; pass a pull request number`); | |
| 171 | + return pr.number; | |
| 172 | +} | |
| 173 | + | |
| 174 | +async function getPull(ctx: Ctx, n: number) { | |
| 175 | + const repo = await ctx.repo(); | |
| 176 | + const api = await ctx.api(); | |
| 177 | + return { repo, api, pr: await api.get<PullDetail>(`${repoPath(repo)}/pulls/${n}`) }; | |
| 178 | +} | |
| 179 | + | |
| 180 | +const view: Command = { | |
| 181 | + name: "pr view", | |
| 182 | + summary: "Show a pull request", | |
| 183 | + description: "Without a number, shows the open pull request for the current branch.", | |
| 184 | + args: "[<number>]", | |
| 185 | + options: { comments: { type: "boolean", short: "c", desc: "Include the conversation" } }, | |
| 186 | + repo: true, | |
| 187 | + json: true, | |
| 188 | + maxArgs: 1, | |
| 189 | + async run(ctx, o, [num]) { | |
| 190 | + const n = await prNumber(ctx, num); | |
| 191 | + const { pr, api, repo } = await getPull(ctx, n); | |
| 192 | + const comments = o.comments ? await api.get<CommentJson[]>(`${repoPath(repo)}/issues/${n}/comments`) : undefined; | |
| 193 | + if (ctx.json) return json(ctx.io, comments ? { ...pr, comment_list: comments } : pr); | |
| 194 | + const c = ctx.c; | |
| 195 | + ctx.out(`${c.bold(pr.title)} ${c.dim(`#${pr.number}`)}`); | |
| 196 | + ctx.out(`${prState(c, pr)} · ${pr.user.login} wants to merge ${c.cyan(pr.head?.ref ?? "?")} into ${c.cyan(pr.base?.ref ?? "?")} · ${ago(pr.created_at)}`); | |
| 197 | + if (pr.intent) ctx.out(`${c.dim("intent:")} ${pr.intent}`); | |
| 198 | + if (pr.state === "open") { | |
| 199 | + ctx.out(`${c.dim("mergeable:")} ${statusText(c, pr.mergeable_state)} · approvals ${pr.approvals}/${pr.required_approvals}`); | |
| 200 | + for (const b of pr.merge_blockers ?? []) ctx.out(` ${c.red("•")} ${b}`); | |
| 201 | + if (pr.auto_merge) { | |
| 202 | + // For an agent, merge_blockers can name an approval its auto-merge doesn't need; say what is really left. | |
| 203 | + const left = pr.auto_merge_blockers; | |
| 204 | + const when = !left ? "it merges by itself once every requirement is met" : left.length ? "it merges by itself once these are done:" : "nothing is left, it merges now"; | |
| 205 | + ctx.out(`${c.dim("auto-merge:")} on (${pr.auto_merge.merge_method}), ${when}`); | |
| 206 | + for (const b of left ?? []) ctx.out(` ${c.yellow("•")} ${b}`); | |
| 207 | + } | |
| 208 | + } | |
| 209 | + if (pr.checks?.length) ctx.out(`${c.dim("checks:")} ${pr.checks.map((k) => `${checkSymbol(ctx, k.state)} ${k.name}`).join(" ")}`); | |
| 210 | + if (pr.body?.trim()) ctx.out(`\n${pr.body.trim()}\n`); | |
| 211 | + for (const cm of comments ?? []) ctx.out(`\n${c.bold(cm.user.login)} ${c.dim(ago(cm.created_at))}\n${cm.body.trim()}`); | |
| 212 | + ctx.out(c.dim(pr.html_url)); | |
| 213 | + }, | |
| 214 | +}; | |
| 215 | + | |
| 216 | +function checkSymbol(ctx: Ctx, state: string): string { | |
| 217 | + const done = ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required", "stale"]; | |
| 218 | + return done.includes(state) ? symbol(ctx.c, "completed", state) : symbol(ctx.c, state); | |
| 219 | +} | |
| 220 | + | |
| 221 | +const diff: Command = { | |
| 222 | + name: "pr diff", | |
| 223 | + summary: "Show a pull request's changes as a unified diff", | |
| 224 | + description: "Without a number, shows the open pull request for the current branch.", | |
| 225 | + args: "[<number>]", | |
| 226 | + options: { "name-only": { type: "boolean", desc: "Only list changed file names" } }, | |
| 227 | + repo: true, | |
| 228 | + maxArgs: 1, | |
| 229 | + async run(ctx, o, [num]) { | |
| 230 | + const n = await prNumber(ctx, num); | |
| 231 | + const repo = await ctx.repo(); | |
| 232 | + const files = await (await ctx.api()).get<Array<{ filename: string; status: string; patch?: string; binary?: boolean }>>(`${repoPath(repo)}/pulls/${n}/files`); | |
| 233 | + if (o["name-only"]) { | |
| 234 | + ctx.io.stdout.write(files.map((f) => `${f.filename}\n`).join("")); | |
| 235 | + return; | |
| 236 | + } | |
| 237 | + const c = ctx.c; | |
| 238 | + for (const f of files) { | |
| 239 | + const patch = f.patch ?? `diff --git a/${f.filename} b/${f.filename}\n`; | |
| 240 | + if (!c.enabled) { | |
| 241 | + ctx.io.stdout.write(patch.endsWith("\n") ? patch : `${patch}\n`); | |
| 242 | + continue; | |
| 243 | + } | |
| 244 | + for (const line of patch.replace(/\n$/, "").split("\n")) { | |
| 245 | + if (line.startsWith("diff --git") || line.startsWith("--- ") || line.startsWith("+++ ")) ctx.out(c.bold(line)); | |
| 246 | + else if (line.startsWith("@@")) ctx.out(c.cyan(line)); | |
| 247 | + else if (line.startsWith("+")) ctx.out(c.green(line)); | |
| 248 | + else if (line.startsWith("-")) ctx.out(c.red(line)); | |
| 249 | + else ctx.out(line); | |
| 250 | + } | |
| 251 | + } | |
| 252 | + }, | |
| 253 | +}; | |
| 254 | + | |
| 255 | +const DONE = ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required"]; | |
| 256 | +const FAILED = ["failure", "cancelled", "timed_out", "action_required"]; | |
| 257 | +const PENDING = ["queued", "in_progress", "waiting", "pending", "requested"]; | |
| 258 | +/** Right after a push, a pull request has no checks until its runs start: --watch waits this long for the first. */ | |
| 259 | +const FIRST_CHECK_WAIT_MS = 120_000; | |
| 260 | + | |
| 261 | +const checks: Command = { | |
| 262 | + name: "pr checks", | |
| 263 | + summary: "Show check status for a pull request", | |
| 264 | + description: | |
| 265 | + "Without a number, shows the open pull request for the current branch. With --watch, polls until no check is pending (waiting up to 2 minutes for the first check to appear, as right after a push), printing the checks whenever they change (on a terminal; otherwise once at the end).\n\nExit status: 0 when all checks passed, 1 when any failed, 8 when some are still pending (never with --watch).", | |
| 266 | + args: "[<number>]", | |
| 267 | + options: { | |
| 268 | + watch: { type: "boolean", desc: "Wait until no check is pending" }, | |
| 269 | + interval: { type: "string", short: "i", desc: "Seconds between polls with --watch (default 10)", arg: "SECONDS" }, | |
| 270 | + }, | |
| 271 | + repo: true, | |
| 272 | + json: true, | |
| 273 | + maxArgs: 1, | |
| 274 | + async run(ctx, o, [num]) { | |
| 275 | + const interval = Math.max(0, Number(str(o, "interval") ?? 10)) * 1000; | |
| 276 | + if (!Number.isFinite(interval)) throw new CliError("--interval must be a number", 2); | |
| 277 | + const n = await prNumber(ctx, num); | |
| 278 | + const repo = await ctx.repo(); | |
| 279 | + const api = await ctx.api(); | |
| 280 | + const render = (list: PullDetail["checks"]) => table(list.map((k) => [checkSymbol(ctx, k.state), k.name, statusText(ctx.c, DONE.includes(k.state) ? "completed" : k.state, k.state)])); | |
| 281 | + // Like `gh pr checks --watch`: on a terminal, show progress as it happens; when piped, only the result. | |
| 282 | + const live = o.watch === true && !ctx.json && !!ctx.io.stdout.isTTY; | |
| 283 | + let shown: string | undefined; | |
| 284 | + let failures = 0; | |
| 285 | + let waited = 0; | |
| 286 | + let list: PullDetail["checks"]; | |
| 287 | + for (;;) { | |
| 288 | + try { | |
| 289 | + list = (await api.get<PullDetail>(`${repoPath(repo)}/pulls/${n}`)).checks ?? []; | |
| 290 | + failures = 0; | |
| 291 | + } catch (e) { | |
| 292 | + // While watching, ride out transient network/server errors; give up on client errors. | |
| 293 | + const status = (e as { status?: number }).status; | |
| 294 | + if (o.watch !== true || (status && status < 500 && status !== 429) || ++failures > 5) throw e; | |
| 295 | + await ctx.io.sleep(interval); | |
| 296 | + continue; | |
| 297 | + } | |
| 298 | + const waitingForFirst = !list.length && waited < FIRST_CHECK_WAIT_MS; | |
| 299 | + if (o.watch !== true || (!waitingForFirst && !list.some((k) => PENDING.includes(k.state)))) break; | |
| 300 | + const t = render(list); | |
| 301 | + if (live && t && t !== shown) { | |
| 302 | + ctx.io.stdout.write(`${shown === undefined ? "" : "\n"}${t}`); | |
| 303 | + shown = t; | |
| 304 | + } | |
| 305 | + await ctx.io.sleep(interval); | |
| 306 | + waited += Math.max(interval, 1000); | |
| 307 | + } | |
| 308 | + if (ctx.json) json(ctx.io, list); | |
| 309 | + else if (!list.length) ctx.err("No checks reported for this pull request."); | |
| 310 | + else if (render(list) !== shown) ctx.io.stdout.write(`${shown === undefined ? "" : "\n"}${render(list)}`); | |
| 311 | + const failed = list.some((k) => FAILED.includes(k.state)); | |
| 312 | + const pending = list.some((k) => PENDING.includes(k.state)); | |
| 313 | + throw new ExitCode(failed ? 1 : pending ? 8 : 0); | |
| 314 | + }, | |
| 315 | +}; | |
| 316 | + | |
| 317 | +const review: Command = { | |
| 318 | + name: "pr review", | |
| 319 | + summary: "Approve, request changes or comment on a pull request", | |
| 320 | + description: "Without a number, reviews the open pull request for the current branch.", | |
| 321 | + args: "[<number>]", | |
| 322 | + options: { | |
| 323 | + approve: { type: "boolean", short: "a", desc: "Approve" }, | |
| 324 | + "request-changes": { type: "boolean", short: "r", desc: "Request changes" }, | |
| 325 | + comment: { type: "boolean", short: "c", desc: "Comment without approving" }, | |
| 326 | + ...bodyOpts, | |
| 327 | + }, | |
| 328 | + repo: true, | |
| 329 | + json: true, | |
| 330 | + maxArgs: 1, | |
| 331 | + async run(ctx, o, [num]) { | |
| 332 | + const which = exactlyOne(o, ["approve", "request-changes", "comment"]); | |
| 333 | + const event = which === "approve" ? "APPROVE" : which === "request-changes" ? "REQUEST_CHANGES" : "COMMENT"; | |
| 334 | + const body = await bodyFrom(ctx, o); | |
| 335 | + if (event !== "APPROVE" && !body) throw new CliError(`--body is required with --${which}`, 2); | |
| 336 | + const n = await prNumber(ctx, num); | |
| 337 | + const repo = await ctx.repo(); | |
| 338 | + const r = await (await ctx.authedApi()).post<{ id: string; state: string }>(`${repoPath(repo)}/pulls/${n}/reviews`, { event, body }); | |
| 339 | + if (ctx.json) return json(ctx.io, r); | |
| 340 | + const verb = event === "APPROVE" ? "Approved" : event === "REQUEST_CHANGES" ? "Requested changes on" : "Reviewed"; | |
| 341 | + ctx.out(`${ctx.c.green("✓")} ${verb} #${n}`); | |
| 342 | + }, | |
| 343 | +}; | |
| 344 | + | |
| 345 | +const merge: Command = { | |
| 346 | + name: "pr merge", | |
| 347 | + summary: "Merge a pull request", | |
| 348 | + description: "Without a number, merges the open pull request for the current branch.", | |
| 349 | + args: "[<number>]", | |
| 350 | + options: { | |
| 351 | + merge: { type: "boolean", short: "m", desc: "Create a merge commit (default)" }, | |
| 352 | + squash: { type: "boolean", short: "s", desc: "Squash into one commit" }, | |
| 353 | + rebase: { type: "boolean", short: "r", desc: "Rebase the commits onto the base branch" }, | |
| 354 | + "delete-branch": { type: "boolean", short: "d", desc: "Delete the head branch after merging" }, | |
| 355 | + subject: { type: "string", short: "t", desc: "Commit title", arg: "TEXT" }, | |
| 356 | + ...bodyOpts, | |
| 357 | + "match-head-commit": { type: "string", desc: "Only merge if the head is still at this SHA", arg: "SHA" }, | |
| 358 | + admin: { type: "boolean", desc: "Merge now, bypassing unmet branch rules and any merge queue (repository admins; audited)" }, | |
| 359 | + auto: { type: "boolean", desc: "Turn on auto-merge: merge (or join the merge queue) once every requirement is met" }, | |
| 360 | + "disable-auto": { type: "boolean", desc: "Turn off auto-merge (and take the pull request out of the merge queue if it is in it)" }, | |
| 361 | + }, | |
| 362 | + repo: true, | |
| 363 | + json: true, | |
| 364 | + maxArgs: 1, | |
| 365 | + async run(ctx, o, [num]) { | |
| 366 | + const method = exactlyOne(o, ["merge", "squash", "rebase"], "merge"); | |
| 367 | + const explicit = ["merge", "squash", "rebase"].some((k) => o[k] === true); | |
| 368 | + if (o.auto === true && o["disable-auto"] === true) throw new CliError("use only one of --auto, --disable-auto", 2); | |
| 369 | + if (o.auto === true && o.admin === true) throw new CliError("--admin merges now; it can't be combined with --auto", 2); | |
| 370 | + const n = await prNumber(ctx, num); | |
| 371 | + const repo = await ctx.repo(); | |
| 372 | + const api = await ctx.authedApi(); | |
| 373 | + if (o["disable-auto"] === true) { | |
| 374 | + // Like `gh pr merge --disable-auto`: turn auto-merge off. Diffinery should not land it by | |
| 375 | + // itself afterwards, so a pull request auto-merge (or someone) already queued leaves the queue too. | |
| 376 | + await api.delete(`${repoPath(repo)}/pulls/${n}/auto-merge`); | |
| 377 | + let dequeued = true; | |
| 378 | + try { | |
| 379 | + await api.delete(`${repoPath(repo)}/pulls/${n}/merge-queue`); | |
| 380 | + } catch (e) { | |
| 381 | + if (!(e instanceof ApiError) || e.status !== 404) throw e; | |
| 382 | + dequeued = false; | |
| 383 | + } | |
| 384 | + if (ctx.json) return json(ctx.io, { auto_merge: null, dequeued }); | |
| 385 | + return ctx.out(`${ctx.c.green("✓")} Turned off auto-merge for #${n}${dequeued ? " and removed it from the merge queue" : ""}`); | |
| 386 | + } | |
| 387 | + if (o.auto === true) { | |
| 388 | + // Like `gh pr merge --auto`: PUT /pulls/{n}/auto-merge. Without a method flag the server picks | |
| 389 | + // the merge queue's method, squash where linear history is required, else a merge commit. | |
| 390 | + const r = await api.put<{ state: "enabled" | "queued" | "merged"; auto_merge: { merge_method: string } | null; merge_commit_sha: string | null }>(`${repoPath(repo)}/pulls/${n}/auto-merge`, { | |
| 391 | + merge_method: explicit ? method : undefined, | |
| 392 | + delete_branch: o["delete-branch"] === true ? true : undefined, | |
| 393 | + commit_title: str(o, "subject"), | |
| 394 | + commit_message: await bodyFrom(ctx, o), | |
| 395 | + sha: str(o, "match-head-commit"), | |
| 396 | + }); | |
| 397 | + if (ctx.json) return json(ctx.io, r); | |
| 398 | + if (r.state === "merged") return ctx.out(`${ctx.c.green("✓")} Merged #${n} ${ctx.c.dim(String(r.merge_commit_sha ?? "").slice(0, 12))}`); | |
| 399 | + if (r.state === "queued") return ctx.out(`${ctx.c.green("✓")} Added #${n} to the merge queue`); | |
| 400 | + return ctx.out(`${ctx.c.green("✓")} Auto-merge is on for #${n} (${r.auto_merge?.merge_method ?? method}): it merges once every requirement is met`); | |
| 401 | + } | |
| 402 | + const payload = { | |
| 403 | + merge_method: method, | |
| 404 | + delete_branch: o["delete-branch"] === true ? true : undefined, | |
| 405 | + commit_title: str(o, "subject"), | |
| 406 | + commit_message: await bodyFrom(ctx, o), | |
| 407 | + sha: str(o, "match-head-commit"), | |
| 408 | + bypass_merge_queue: o.admin === true ? true : undefined, | |
| 409 | + bypass_rules: o.admin === true ? true : undefined, | |
| 410 | + }; | |
| 411 | + let r: { merged: boolean; sha: string; method: string }; | |
| 412 | + try { | |
| 413 | + r = await api.put(`${repoPath(repo)}/pulls/${n}/merge`, payload); | |
| 414 | + } catch (e) { | |
| 415 | + // Like `gh pr merge`: a base branch with a merge queue gets the pull request queued instead. | |
| 416 | + if (!(e instanceof ApiError) || e.status !== 405 || !/merge queue/.test(e.message) || o.admin === true) throw e; | |
| 417 | + const q = await api.put<{ state: string; entry: { position: number } | null; merge_commit_sha: string | null; reason: string | null }>(`${repoPath(repo)}/pulls/${n}/merge-queue`, { | |
| 418 | + merge_method: explicit ? method : undefined, | |
| 419 | + delete_branch: payload.delete_branch, | |
| 420 | + }); | |
| 421 | + if (ctx.json) return json(ctx.io, q); | |
| 422 | + if (q.state === "queued") return ctx.out(`${ctx.c.green("✓")} Added #${n} to the merge queue (position ${q.entry?.position ?? "?"})`); | |
| 423 | + if (q.state === "merged") return ctx.out(`${ctx.c.green("✓")} Merged #${n} through the merge queue ${ctx.c.dim(String(q.merge_commit_sha ?? "").slice(0, 12))}`); | |
| 424 | + throw new CliError(`#${n} left the merge queue: ${q.reason ?? "see the pull request"}`); | |
| 425 | + } | |
| 426 | + if (ctx.json) return json(ctx.io, r); | |
| 427 | + ctx.out(`${ctx.c.green("✓")} Merged #${n} (${r.method ?? method}) ${ctx.c.dim(String(r.sha ?? "").slice(0, 12))}${payload.delete_branch ? " and deleted the branch" : ""}`); | |
| 428 | + }, | |
| 429 | +}; | |
| 430 | + | |
| 431 | +const comment: Command = { | |
| 432 | + name: "pr comment", | |
| 433 | + summary: "Comment on a pull request", | |
| 434 | + description: "Without a number, comments on the open pull request for the current branch.", | |
| 435 | + args: "[<number>]", | |
| 436 | + options: bodyOpts, | |
| 437 | + repo: true, | |
| 438 | + json: true, | |
| 439 | + maxArgs: 1, | |
| 440 | + async run(ctx, o, [num]) { | |
| 441 | + const body = await bodyFrom(ctx, o, true); | |
| 442 | + if (!body?.trim()) throw new CliError("--body is required", 2); | |
| 443 | + const n = await prNumber(ctx, num); | |
| 444 | + const repo = await ctx.repo(); | |
| 445 | + const r = await (await ctx.authedApi()).post<CommentJson>(`${repoPath(repo)}/issues/${n}/comments`, { body }); | |
| 446 | + if (ctx.json) return json(ctx.io, r); | |
| 447 | + ctx.out(`${ctx.c.green("✓")} Commented on #${n}`); | |
| 448 | + }, | |
| 449 | +}; | |
| 450 | + | |
| 451 | +const ready: Command = { | |
| 452 | + name: "pr ready", | |
| 453 | + summary: "Mark a draft pull request ready for review", | |
| 454 | + description: "Without a number, uses the open pull request for the current branch.", | |
| 455 | + args: "[<number>]", | |
| 456 | + options: { undo: { type: "boolean", desc: "Convert the pull request back to a draft" } }, | |
| 457 | + repo: true, | |
| 458 | + json: true, | |
| 459 | + maxArgs: 1, | |
| 460 | + async run(ctx, o, [num]) { | |
| 461 | + const n = await prNumber(ctx, num); | |
| 462 | + const repo = await ctx.repo(); | |
| 463 | + const pr = await (await ctx.authedApi()).patch<IssueJson>(`${repoPath(repo)}/pulls/${n}`, { draft: o.undo === true }); | |
| 464 | + if (ctx.json) return json(ctx.io, pr); | |
| 465 | + ctx.out(`${ctx.c.green("✓")} ${o.undo === true ? `Converted #${n} to a draft` : `Marked #${n} ready for review`}`); | |
| 466 | + }, | |
| 467 | +}; | |
| 468 | + | |
| 469 | +export const prGroup: Group = { name: "pr", summary: "Work with pull requests", commands: [create, listCmd, view, diff, checks, review, merge, ready, comment] }; |
addedapps/cli/src/commands/repo.ts+113−0View file
| @@ -1,0 +1,113 @@ | ||
| 1 | +import path from "node:path"; | |
| 2 | +import { enc } from "../api.js"; | |
| 3 | +import { exactlyOne, parseRepoArg, repoPath, str, type Command, type Group } from "../cli.js"; | |
| 4 | +import { basicAuthHeader, gitInteractive, withGitConfig } from "../git.js"; | |
| 5 | +import { CliError } from "../io.js"; | |
| 6 | +import { ago, json, table, truncate } from "../output.js"; | |
| 7 | + | |
| 8 | +export interface RepoJson { | |
| 9 | + id: string; | |
| 10 | + name: string; | |
| 11 | + full_name: string; | |
| 12 | + owner: { login: string; type: string }; | |
| 13 | + private: boolean; | |
| 14 | + visibility: "public" | "private"; | |
| 15 | + description: string | null; | |
| 16 | + default_branch: string; | |
| 17 | + archived: boolean; | |
| 18 | + html_url: string; | |
| 19 | + clone_url: string; | |
| 20 | + pushed_at: string | null; | |
| 21 | + updated_at: string; | |
| 22 | + permissions?: { role: string }; | |
| 23 | + stats?: Record<string, unknown>; | |
| 24 | +} | |
| 25 | + | |
| 26 | +const create: Command = { | |
| 27 | + name: "repo create", | |
| 28 | + summary: "Create a repository", | |
| 29 | + args: "<name>", | |
| 30 | + options: { | |
| 31 | + org: { type: "string", desc: "Create it in this organization", arg: "ORG" }, | |
| 32 | + private: { type: "boolean", desc: "Only collaborators can see it (default)" }, | |
| 33 | + public: { type: "boolean", desc: "Anyone can see it" }, | |
| 34 | + description: { type: "string", short: "d", desc: "Description", arg: "TEXT" }, | |
| 35 | + "default-branch": { type: "string", desc: "Default branch name", arg: "BRANCH" }, | |
| 36 | + }, | |
| 37 | + json: true, | |
| 38 | + minArgs: 1, | |
| 39 | + maxArgs: 1, | |
| 40 | + async run(ctx, o, [name]) { | |
| 41 | + const visibility = exactlyOne(o, ["private", "public"], "private"); | |
| 42 | + const api = await ctx.authedApi(); | |
| 43 | + const org = str(o, "org"); | |
| 44 | + const r = await api.post<RepoJson>(org ? `/orgs/${enc(org)}/repos` : "/user/repos", { name, description: str(o, "description"), visibility, default_branch: str(o, "default-branch") }); | |
| 45 | + if (ctx.json) return json(ctx.io, r); | |
| 46 | + ctx.out(`${ctx.c.green("✓")} Created ${ctx.c.bold(r.full_name)} (${r.visibility})`); | |
| 47 | + ctx.out(r.html_url); | |
| 48 | + ctx.err(ctx.c.dim(`git remote add origin ${r.clone_url}`)); | |
| 49 | + }, | |
| 50 | +}; | |
| 51 | + | |
| 52 | +const listCmd: Command = { | |
| 53 | + name: "repo list", | |
| 54 | + summary: "List repositories you can see", | |
| 55 | + options: { owner: { type: "string", desc: "Only repositories owned by this user or organization", arg: "OWNER" } }, | |
| 56 | + json: true, | |
| 57 | + maxArgs: 0, | |
| 58 | + async run(ctx, o) { | |
| 59 | + const api = await ctx.api(); | |
| 60 | + const owner = str(o, "owner"); | |
| 61 | + // The server has no /users/:login/repos; /orgs/:owner/repos accepts any account login. | |
| 62 | + const repos = await api.get<RepoJson[]>(owner ? `/users/${enc(owner)}/repos` : "/user/repos"); | |
| 63 | + if (ctx.json) return json(ctx.io, repos); | |
| 64 | + if (!repos.length) return ctx.err("No repositories."); | |
| 65 | + ctx.io.stdout.write(table(repos.map((r) => [ctx.c.bold(r.full_name), r.archived ? ctx.c.dim("archived") : r.visibility, truncate(r.description, 50), ctx.c.dim(ago(r.pushed_at ?? r.updated_at))]))); | |
| 66 | + }, | |
| 67 | +}; | |
| 68 | + | |
| 69 | +const view: Command = { | |
| 70 | + name: "repo view", | |
| 71 | + summary: "Show a repository", | |
| 72 | + args: "[OWNER/NAME]", | |
| 73 | + repo: true, | |
| 74 | + json: true, | |
| 75 | + maxArgs: 1, | |
| 76 | + async run(ctx, _o, [arg]) { | |
| 77 | + const ref = arg ? parseRepoArg(arg) : await ctx.repo(); | |
| 78 | + const r = await (await ctx.api()).get<RepoJson>(repoPath(ref)); | |
| 79 | + if (ctx.json) return json(ctx.io, r); | |
| 80 | + const c = ctx.c; | |
| 81 | + ctx.out(`${c.bold(r.full_name)} ${c.dim(r.visibility)}${r.archived ? c.yellow(" archived") : ""}`); | |
| 82 | + if (r.description) ctx.out(r.description); | |
| 83 | + const rows: string[][] = [ | |
| 84 | + ["default branch", r.default_branch], | |
| 85 | + ["web", r.html_url], | |
| 86 | + ["clone", r.clone_url], | |
| 87 | + ]; | |
| 88 | + if (r.permissions?.role) rows.push(["your role", r.permissions.role]); | |
| 89 | + if (r.pushed_at) rows.push(["last push", ago(r.pushed_at)]); | |
| 90 | + for (const [k, v] of Object.entries(r.stats ?? {})) if (typeof v === "number" || typeof v === "string") rows.push([k.replace(/_/g, " "), String(v)]); | |
| 91 | + ctx.io.stdout.write(table(rows.map(([k, v]) => [c.dim(k!), v!]))); | |
| 92 | + }, | |
| 93 | +}; | |
| 94 | + | |
| 95 | +const clone: Command = { | |
| 96 | + name: "repo clone", | |
| 97 | + summary: "Clone a repository", | |
| 98 | + description: "The token is passed to this one git command as an HTTP header and is never written to .git/config.\nRun 'diffinery auth setup-git' so later fetches and pushes authenticate too.", | |
| 99 | + args: "<OWNER/NAME> [directory]", | |
| 100 | + minArgs: 1, | |
| 101 | + maxArgs: 2, | |
| 102 | + async run(ctx, _o, [arg, dir]) { | |
| 103 | + const ref = parseRepoArg(arg!); | |
| 104 | + const cfg = await ctx.config(); | |
| 105 | + const url = `${cfg.url}/${ref.owner}/${ref.name}.git`; | |
| 106 | + const env = cfg.token ? withGitConfig(ctx.io.env, [basicAuthHeader(cfg.url, "x-token", cfg.token), ["protocol.version", "2"]]) : withGitConfig(ctx.io.env, [["protocol.version", "2"]]); | |
| 107 | + const code = await gitInteractive(ctx.io, ["clone", url, ...(dir ? [dir] : [])], { env }); | |
| 108 | + if (code !== 0) throw new CliError(`git clone exited with ${code}`, code); | |
| 109 | + ctx.err(ctx.c.dim(`Cloned into ${dir ?? path.basename(ref.name)}. Tip: 'diffinery auth setup-git' lets plain git push/fetch authenticate.`)); | |
| 110 | + }, | |
| 111 | +}; | |
| 112 | + | |
| 113 | +export const repoGroup: Group = { name: "repo", summary: "Create, list, view and clone repositories", commands: [create, listCmd, view, clone] }; |
addedapps/cli/src/commands/run.ts+218−0View file
| @@ -1,0 +1,218 @@ | ||
| 1 | +import { enc } from "../api.js"; | |
| 2 | +import { positiveInt, repoPath, str, type Command, type Ctx, type Group } from "../cli.js"; | |
| 3 | +import { CliError, ExitCode } from "../io.js"; | |
| 4 | +import { ago, json, statusText, symbol, table, truncate } from "../output.js"; | |
| 5 | + | |
| 6 | +export interface RunJson { | |
| 7 | + id: string; | |
| 8 | + run_number: number; | |
| 9 | + run_attempt: number; | |
| 10 | + name: string; | |
| 11 | + display_title: string; | |
| 12 | + path: string; | |
| 13 | + event: string; | |
| 14 | + head_branch: string; | |
| 15 | + head_sha: string; | |
| 16 | + status: string; | |
| 17 | + conclusion: string | null; | |
| 18 | + actor: { login: string }; | |
| 19 | + created_at: string; | |
| 20 | + updated_at: string; | |
| 21 | + html_url: string; | |
| 22 | + pull_request_number: number | null; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export interface JobJson { | |
| 26 | + id: string; | |
| 27 | + runId: string; | |
| 28 | + key: string; | |
| 29 | + name: string; | |
| 30 | + status: string; | |
| 31 | + conclusion: string | null; | |
| 32 | + startedAt: string | null; | |
| 33 | + completedAt: string | null; | |
| 34 | + steps: Array<{ index: number; name?: string; status?: string; conclusion?: string | null }>; | |
| 35 | + failure: { message?: string } | null; | |
| 36 | +} | |
| 37 | + | |
| 38 | +type RunDetail = RunJson & { jobs: JobJson[] }; | |
| 39 | + | |
| 40 | +function duration(a: string | null, b: string | null): string { | |
| 41 | + if (!a) return ""; | |
| 42 | + const ms = (b ? Date.parse(b) : Date.now()) - Date.parse(a); | |
| 43 | + if (!Number.isFinite(ms) || ms < 0) return ""; | |
| 44 | + const s = Math.round(ms / 1000); | |
| 45 | + return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`; | |
| 46 | +} | |
| 47 | + | |
| 48 | +const listCmd: Command = { | |
| 49 | + name: "run list", | |
| 50 | + summary: "List workflow runs", | |
| 51 | + options: { | |
| 52 | + branch: { type: "string", short: "b", desc: "Only runs for this branch", arg: "BRANCH" }, | |
| 53 | + workflow: { type: "string", short: "w", desc: "Only runs of this workflow (file name or path)", arg: "FILE" }, | |
| 54 | + status: { type: "string", short: "s", desc: "Only runs with this status (queued, in_progress, completed, ...)", arg: "STATUS" }, | |
| 55 | + event: { type: "string", short: "e", desc: "Only runs triggered by this event", arg: "EVENT" }, | |
| 56 | + commit: { type: "string", short: "c", desc: "Only runs for this commit SHA", arg: "SHA" }, | |
| 57 | + limit: { type: "string", short: "L", desc: "Maximum number to show (default 20)", arg: "N" }, | |
| 58 | + }, | |
| 59 | + repo: true, | |
| 60 | + json: true, | |
| 61 | + maxArgs: 0, | |
| 62 | + async run(ctx, o) { | |
| 63 | + const repo = await ctx.repo(); | |
| 64 | + const limit = str(o, "limit") ? positiveInt(str(o, "limit"), "--limit") : 20; | |
| 65 | + const res = await (await ctx.api()).get<{ total_count: number; workflow_runs: RunJson[] }>(`${repoPath(repo)}/actions/runs`, { | |
| 66 | + branch: str(o, "branch"), | |
| 67 | + workflow: str(o, "workflow"), | |
| 68 | + status: str(o, "status"), | |
| 69 | + event: str(o, "event"), | |
| 70 | + head_sha: str(o, "commit"), | |
| 71 | + per_page: Math.min(limit, 100), | |
| 72 | + }); | |
| 73 | + if (ctx.json) return json(ctx.io, res.workflow_runs); | |
| 74 | + if (!res.workflow_runs.length) return ctx.err("No runs found."); | |
| 75 | + const c = ctx.c; | |
| 76 | + ctx.io.stdout.write( | |
| 77 | + table([ | |
| 78 | + ["", "TITLE", "WORKFLOW", "BRANCH", "EVENT", "ID", "AGE"].map((h) => c.dim(h)), | |
| 79 | + ...res.workflow_runs.map((r) => [symbol(c, r.status, r.conclusion), truncate(r.display_title, 50), r.name, c.cyan(r.head_branch), r.event, r.id, c.dim(ago(r.created_at))]), | |
| 80 | + ]), | |
| 81 | + ); | |
| 82 | + }, | |
| 83 | +}; | |
| 84 | + | |
| 85 | +function printRun(ctx: Ctx, r: RunDetail): void { | |
| 86 | + const c = ctx.c; | |
| 87 | + ctx.out(`${symbol(c, r.status, r.conclusion)} ${c.bold(r.display_title)} ${c.dim(`${r.name} #${r.run_number}${r.run_attempt > 1 ? ` (attempt ${r.run_attempt})` : ""}`)}`); | |
| 88 | + ctx.out(`${statusText(c, r.status, r.conclusion)} · ${r.event} on ${c.cyan(r.head_branch)} ${c.dim(r.head_sha.slice(0, 7))} · ${r.actor.login} · ${ago(r.created_at)}`); | |
| 89 | + ctx.out(""); | |
| 90 | + ctx.io.stdout.write(table(r.jobs.map((j) => [symbol(c, j.status, j.conclusion), j.name, statusText(c, j.status, j.conclusion), c.dim(duration(j.startedAt, j.completedAt)), c.dim(`job ${j.id}`)]))); | |
| 91 | + for (const j of r.jobs) if (j.failure?.message) ctx.out(`${c.red("X")} ${j.name}: ${j.failure.message}`); | |
| 92 | + ctx.out(c.dim(r.html_url)); | |
| 93 | +} | |
| 94 | + | |
| 95 | +const view: Command = { | |
| 96 | + name: "run view", | |
| 97 | + summary: "Show a run and its jobs", | |
| 98 | + args: "<run-id>", | |
| 99 | + repo: true, | |
| 100 | + json: true, | |
| 101 | + minArgs: 1, | |
| 102 | + maxArgs: 1, | |
| 103 | + async run(ctx, _o, [id]) { | |
| 104 | + const repo = await ctx.repo(); | |
| 105 | + const r = await (await ctx.api()).get<RunDetail>(`${repoPath(repo)}/actions/runs/${enc(id!)}`); | |
| 106 | + if (ctx.json) return json(ctx.io, r); | |
| 107 | + printRun(ctx, r); | |
| 108 | + }, | |
| 109 | +}; | |
| 110 | + | |
| 111 | +const watch: Command = { | |
| 112 | + name: "run watch", | |
| 113 | + summary: "Follow a run until it completes", | |
| 114 | + description: "Polls every 3 seconds and prints job status changes. Exits non-zero unless the run succeeds.", | |
| 115 | + args: "<run-id>", | |
| 116 | + options: { interval: { type: "string", short: "i", desc: "Seconds between polls (default 3)", arg: "SECONDS" } }, | |
| 117 | + repo: true, | |
| 118 | + minArgs: 1, | |
| 119 | + maxArgs: 1, | |
| 120 | + async run(ctx, o, [id]) { | |
| 121 | + const repo = await ctx.repo(); | |
| 122 | + const api = await ctx.api(); | |
| 123 | + const interval = Math.max(0, Number(str(o, "interval") ?? 3)) * 1000; | |
| 124 | + if (!Number.isFinite(interval)) throw new CliError("--interval must be a number", 2); | |
| 125 | + const c = ctx.c; | |
| 126 | + const seen = new Map<string, string>(); | |
| 127 | + let first = true; | |
| 128 | + let failures = 0; | |
| 129 | + for (;;) { | |
| 130 | + let r: RunDetail; | |
| 131 | + try { | |
| 132 | + r = await api.get<RunDetail>(`${repoPath(repo)}/actions/runs/${enc(id!)}`); | |
| 133 | + failures = 0; | |
| 134 | + } catch (e) { | |
| 135 | + // Ride out transient network/server errors; give up on client errors. | |
| 136 | + const status = (e as { status?: number }).status; | |
| 137 | + if ((status && status < 500 && status !== 429) || ++failures > 5) throw e; | |
| 138 | + await ctx.io.sleep(interval); | |
| 139 | + continue; | |
| 140 | + } | |
| 141 | + if (first) { | |
| 142 | + ctx.out(`${c.bold(r.display_title)} ${c.dim(`${r.name} #${r.run_number} · ${r.html_url}`)}`); | |
| 143 | + first = false; | |
| 144 | + } | |
| 145 | + for (const j of r.jobs) { | |
| 146 | + const key = `${j.status}/${j.conclusion ?? ""}`; | |
| 147 | + if (seen.get(j.id) === key) continue; | |
| 148 | + seen.set(j.id, key); | |
| 149 | + const extra = j.status === "completed" ? ` ${c.dim(duration(j.startedAt, j.completedAt))}` : ""; | |
| 150 | + ctx.out(`${symbol(c, j.status, j.conclusion)} ${j.name}: ${statusText(c, j.status, j.conclusion)}${extra}${j.failure?.message && j.status === "completed" ? ` ${c.dim(`(${j.failure.message})`)}` : ""}`); | |
| 151 | + } | |
| 152 | + if (r.status === "completed") { | |
| 153 | + ctx.out(`${symbol(c, r.status, r.conclusion)} Run ${r.id} completed: ${statusText(c, r.status, r.conclusion)}`); | |
| 154 | + if (r.conclusion !== "success") { | |
| 155 | + const failed = r.jobs.find((j) => j.conclusion === "failure" || j.conclusion === "timed_out"); | |
| 156 | + if (failed) ctx.err(c.dim(`See the log: diffinery run logs ${failed.id} -R ${repo.owner}/${repo.name}`)); | |
| 157 | + } | |
| 158 | + throw new ExitCode(r.conclusion === "success" ? 0 : 1); | |
| 159 | + } | |
| 160 | + await ctx.io.sleep(interval); | |
| 161 | + } | |
| 162 | + }, | |
| 163 | +}; | |
| 164 | + | |
| 165 | +const logs: Command = { | |
| 166 | + name: "run logs", | |
| 167 | + summary: "Print a job's log", | |
| 168 | + args: "<job-id>", | |
| 169 | + repo: true, | |
| 170 | + json: true, | |
| 171 | + minArgs: 1, | |
| 172 | + maxArgs: 1, | |
| 173 | + async run(ctx, _o, [id]) { | |
| 174 | + const repo = await ctx.repo(); | |
| 175 | + const api = await ctx.api(); | |
| 176 | + if (ctx.json) return json(ctx.io, await api.get(`${repoPath(repo)}/actions/jobs/${enc(id!)}/logs`, { format: "json" })); | |
| 177 | + const res = await api.raw<string>("GET", `${repoPath(repo)}/actions/jobs/${enc(id!)}/logs`, { text: true }); | |
| 178 | + ctx.io.stdout.write(res.data.endsWith("\n") || !res.data ? res.data : `${res.data}\n`); | |
| 179 | + }, | |
| 180 | +}; | |
| 181 | + | |
| 182 | +const rerun: Command = { | |
| 183 | + name: "run rerun", | |
| 184 | + summary: "Re-run a workflow run", | |
| 185 | + args: "[run-id]", | |
| 186 | + options: { | |
| 187 | + failed: { type: "boolean", desc: "Only re-run the failed jobs and the jobs that depend on them" }, | |
| 188 | + job: { type: "string", short: "j", desc: "Re-run this job and the jobs that depend on it (no run id needed)", arg: "JOB-ID" }, | |
| 189 | + }, | |
| 190 | + repo: true, | |
| 191 | + json: true, | |
| 192 | + maxArgs: 1, | |
| 193 | + async run(ctx, o, [id]) { | |
| 194 | + const repo = await ctx.repo(); | |
| 195 | + const job = str(o, "job"); | |
| 196 | + if (!job && !id) throw new CliError("give a run id, or --job <job-id>", 2); | |
| 197 | + const path = job ? `actions/jobs/${enc(job)}/rerun` : `actions/runs/${enc(id!)}/${o.failed ? "rerun-failed-jobs" : "rerun"}`; | |
| 198 | + const r = await (await ctx.authedApi()).post<{ run_id: string }>(`${repoPath(repo)}/${path}`); | |
| 199 | + if (ctx.json) return json(ctx.io, r); | |
| 200 | + ctx.out(`${ctx.c.green("✓")} Re-running as run ${r.run_id}`); | |
| 201 | + }, | |
| 202 | +}; | |
| 203 | + | |
| 204 | +const cancel: Command = { | |
| 205 | + name: "run cancel", | |
| 206 | + summary: "Cancel a queued or running run", | |
| 207 | + args: "<run-id>", | |
| 208 | + repo: true, | |
| 209 | + minArgs: 1, | |
| 210 | + maxArgs: 1, | |
| 211 | + async run(ctx, _o, [id]) { | |
| 212 | + const repo = await ctx.repo(); | |
| 213 | + await (await ctx.authedApi()).post(`${repoPath(repo)}/actions/runs/${enc(id!)}/cancel`); | |
| 214 | + ctx.out(`${ctx.c.green("✓")} Cancellation requested for run ${id}`); | |
| 215 | + }, | |
| 216 | +}; | |
| 217 | + | |
| 218 | +export const runGroup: Group = { name: "run", summary: "Inspect and control workflow runs", commands: [listCmd, view, watch, logs, rerun, cancel] }; |
addedapps/cli/src/commands/runner.ts+263−0View file
| @@ -1,0 +1,263 @@ | ||
| 1 | +import { spawn, type ChildProcess } from "node:child_process"; | |
| 2 | +import { existsSync } from "node:fs"; | |
| 3 | +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; | |
| 4 | +import { homedir, hostname } from "node:os"; | |
| 5 | +import path from "node:path"; | |
| 6 | +import { createInterface } from "node:readline"; | |
| 7 | +import { Api, ApiError } from "../api.js"; | |
| 8 | +import { configDir } from "../config.js"; | |
| 9 | +import { list, parseRepoArg, repoPath, str, type Command, type Ctx, type RepoRef } from "../cli.js"; | |
| 10 | +import { CliError, type Io } from "../io.js"; | |
| 11 | + | |
| 12 | +export interface ClaimedJob { | |
| 13 | + job_id: string; | |
| 14 | + run_id: string; | |
| 15 | + name: string; | |
| 16 | + token: string; | |
| 17 | + api_url: string; | |
| 18 | +} | |
| 19 | + | |
| 20 | +const AGENT_HELP = `The runner needs the Diffinery runner agent (a single Node.js file). Build it from the | |
| 21 | +Diffinery repository with: | |
| 22 | + | |
| 23 | + pnpm --filter @diffinery/runner build # produces containers/runner/dist/agent.mjs | |
| 24 | + | |
| 25 | +then pass --agent /path/to/agent.mjs or set DIFFINERY_RUNNER_AGENT.`; | |
| 26 | + | |
| 27 | +export function resolveAgentPath(io: Io, flag: string | undefined): string { | |
| 28 | + const p = flag ?? io.env.DIFFINERY_RUNNER_AGENT; | |
| 29 | + if (!p) throw new CliError(`no runner agent given.\n\n${AGENT_HELP}`); | |
| 30 | + const abs = path.resolve(io.cwd, p); | |
| 31 | + if (!existsSync(abs)) throw new CliError(`runner agent not found at ${abs}.\n\n${AGENT_HELP}`); | |
| 32 | + return abs; | |
| 33 | +} | |
| 34 | + | |
| 35 | +/** POST .../actions/runners/claim: a job, or null when nothing is waiting (204). A registered runner sends an empty body. */ | |
| 36 | +export async function claim(api: Api, repo: RepoRef, body: { name?: string; labels?: string[] }): Promise<ClaimedJob | null> { | |
| 37 | + const res = await api.raw<ClaimedJob | null>("POST", `${repoPath(repo)}/actions/runners/claim`, { body }); | |
| 38 | + if (res.status === 204 || !res.data || typeof res.data !== "object") return null; | |
| 39 | + return res.data; | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** A registered runner's own credentials: the token claims this repository's jobs and nothing else. */ | |
| 43 | +export interface RunnerCredentials { | |
| 44 | + url: string; | |
| 45 | + owner: string; | |
| 46 | + repo: string; | |
| 47 | + id: number; | |
| 48 | + name: string; | |
| 49 | + labels: string[]; | |
| 50 | + token: string; | |
| 51 | +} | |
| 52 | + | |
| 53 | +const safe = (s: string) => s.replace(/[^A-Za-z0-9._-]/g, "_"); | |
| 54 | + | |
| 55 | +/** Where a registered runner's credentials are kept (mode 0600). */ | |
| 56 | +export function runnerFile(env: NodeJS.ProcessEnv, url: string, repo: RepoRef, name: string): string { | |
| 57 | + return path.join(configDir(env), "runners", safe(new URL(url).host), safe(repo.owner), safe(repo.name), `${safe(name)}.json`); | |
| 58 | +} | |
| 59 | + | |
| 60 | +async function readRunner(file: string): Promise<RunnerCredentials | null> { | |
| 61 | + try { | |
| 62 | + const v = JSON.parse(await readFile(file, "utf8")) as RunnerCredentials; | |
| 63 | + return typeof v.token === "string" && v.token.startsWith("drn_") ? v : null; | |
| 64 | + } catch (e) { | |
| 65 | + if ((e as NodeJS.ErrnoException).code === "ENOENT") return null; | |
| 66 | + throw new CliError(`${file} is not a valid runner registration; delete it and register again`); | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +async function saveRunner(file: string, creds: RunnerCredentials): Promise<void> { | |
| 71 | + await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); | |
| 72 | + await writeFile(file, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 }); | |
| 73 | + await chmod(file, 0o600); | |
| 74 | +} | |
| 75 | + | |
| 76 | +/** POST .../actions/runners/register: trade a registration token for the runner's own token. */ | |
| 77 | +export async function register(url: string, repo: RepoRef, registrationToken: string, name: string, labels: string[], ephemeral: boolean): Promise<RunnerCredentials> { | |
| 78 | + const res = await new Api(url, null).raw<{ id: number; name: string; labels: string[]; token: string }>("POST", `${repoPath(repo)}/actions/runners/register`, { | |
| 79 | + body: { registration_token: registrationToken, name, labels, ephemeral }, | |
| 80 | + }); | |
| 81 | + return { url, owner: repo.owner, repo: repo.name, id: res.data.id, name: res.data.name, labels: res.data.labels, token: res.data.token }; | |
| 82 | +} | |
| 83 | + | |
| 84 | +/** How the runner claims from one repository: with its own registered token, or (older servers) the signed-in person's. */ | |
| 85 | +interface Source { | |
| 86 | + repo: RepoRef; | |
| 87 | + api: Api; | |
| 88 | + body: { name?: string; labels?: string[] }; | |
| 89 | + /** The saved registration, when the token came from one. */ | |
| 90 | + file?: string; | |
| 91 | +} | |
| 92 | + | |
| 93 | +/** Environment for the agent: the job's credentials, minus the CLI's own token. */ | |
| 94 | +export function agentEnv(base: NodeJS.ProcessEnv, job: ClaimedJob, workRoot: string, echo: boolean): NodeJS.ProcessEnv { | |
| 95 | + const env: NodeJS.ProcessEnv = { ...base }; | |
| 96 | + for (const k of ["DIFFINERY_TOKEN", "DIFFINERY_URL", "DIFFINERY_RUNNER_AGENT", "GH_TOKEN", "GITHUB_TOKEN"]) delete env[k]; | |
| 97 | + env.DIFFINERY_API_URL = job.api_url; | |
| 98 | + env.DIFFINERY_JOB_ID = job.job_id; | |
| 99 | + env.DIFFINERY_JOB_TOKEN = job.token; | |
| 100 | + env.DIFFINERY_WORK_ROOT ??= workRoot; | |
| 101 | + if (echo) env.DIFFINERY_ECHO_LOGS = "1"; | |
| 102 | + return env; | |
| 103 | +} | |
| 104 | + | |
| 105 | +function pipeLines(stream: NodeJS.ReadableStream, prefix: string, write: (s: string) => void): Promise<void> { | |
| 106 | + return new Promise((resolve) => { | |
| 107 | + const rl = createInterface({ input: stream, crlfDelay: Infinity }); | |
| 108 | + rl.on("line", (l) => write(`${prefix}${l}\n`)); | |
| 109 | + rl.on("close", () => resolve()); | |
| 110 | + }); | |
| 111 | +} | |
| 112 | + | |
| 113 | +async function runJob(ctx: Ctx, agent: string, job: ClaimedJob, repo: RepoRef, workRoot: string, echo: boolean, onChild: (c: ChildProcess | null) => void): Promise<number> { | |
| 114 | + const c = ctx.c; | |
| 115 | + const prefix = c.dim(`[${job.name}] `); | |
| 116 | + ctx.out(`${c.cyan("→")} ${repo.owner}/${repo.name}: running job ${c.bold(job.name)} ${c.dim(`(run ${job.run_id}, job ${job.job_id})`)}`); | |
| 117 | + await mkdir(workRoot, { recursive: true }); | |
| 118 | + const child = spawn(process.execPath, [agent], { cwd: workRoot, env: agentEnv(ctx.io.env, job, workRoot, echo), stdio: ["ignore", "pipe", "pipe"] }); | |
| 119 | + onChild(child); | |
| 120 | + const done = Promise.all([pipeLines(child.stdout!, prefix, (s) => ctx.io.stdout.write(s)), pipeLines(child.stderr!, prefix, (s) => ctx.io.stderr.write(s))]); | |
| 121 | + const code = await new Promise<number>((resolve) => { | |
| 122 | + child.on("error", (e) => { | |
| 123 | + ctx.err(`${c.red("error:")} could not start the agent: ${e.message}`); | |
| 124 | + resolve(1); | |
| 125 | + }); | |
| 126 | + child.on("exit", (code, signal) => resolve(code ?? (signal ? 128 + (signal === "SIGKILL" ? 9 : 15) : 1))); | |
| 127 | + }); | |
| 128 | + await done; | |
| 129 | + onChild(null); | |
| 130 | + ctx.out(`${code === 0 ? c.green("✓") : c.red("X")} job ${job.name} finished${code === 0 ? "" : ` with exit code ${code}`}`); | |
| 131 | + return code; | |
| 132 | +} | |
| 133 | + | |
| 134 | +export const runnerCommand: Command = { | |
| 135 | + name: "runner", | |
| 136 | + summary: "Run self-hosted workflow jobs on this machine", | |
| 137 | + description: `Polls the given repositories for jobs with 'runs-on: [self-hosted, ...]' whose labels this | |
| 138 | +runner offers, and runs each with the Diffinery runner agent. Jobs run directly on this machine | |
| 139 | +with your user's permissions. | |
| 140 | + | |
| 141 | +The runner registers with each repository and claims jobs with its own token, which can't do | |
| 142 | +anything else. Register it with a registration token from a repository admin | |
| 143 | +(POST /repos/OWNER/NAME/actions/runners/registration-token, valid for an hour): | |
| 144 | + | |
| 145 | + diffinery runner -R OWNER/NAME --registration-token drg_... --labels linux | |
| 146 | + | |
| 147 | +so the machine never holds a personal token, which the jobs it runs could read. The runner's | |
| 148 | +credentials are saved under ~/.config/diffinery/runners and used on the next start. Signed in | |
| 149 | +as a repository admin instead, the runner registers itself with your token. | |
| 150 | + | |
| 151 | +--ephemeral registers a runner for one job: its token stops working once it takes the job, and | |
| 152 | +the runner exits when the job finishes. | |
| 153 | + | |
| 154 | +${AGENT_HELP}`, | |
| 155 | + options: { | |
| 156 | + repo: { type: "string", short: "R", multiple: true, desc: "Repository to take jobs from (default: the git origin)", arg: "OWNER/NAME" }, | |
| 157 | + labels: { type: "string", short: "l", desc: "Extra labels this runner offers, comma separated ('self-hosted' is implied)", arg: "a,b" }, | |
| 158 | + name: { type: "string", short: "n", desc: "Runner name (default: host name)", arg: "NAME" }, | |
| 159 | + once: { type: "boolean", desc: "Run at most one job, then exit with its exit code" }, | |
| 160 | + agent: { type: "string", short: "a", desc: "Path to the runner agent (agent.mjs); default DIFFINERY_RUNNER_AGENT", arg: "PATH" }, | |
| 161 | + "work-dir": { type: "string", short: "w", desc: "Working directory for jobs (default ~/.cache/diffinery/runner/<name>)", arg: "DIR" }, | |
| 162 | + interval: { type: "string", desc: "Seconds between polls when idle (default 5)", arg: "SECONDS" }, | |
| 163 | + quiet: { type: "boolean", short: "q", desc: "Do not echo step output locally (logs still go to the server)" }, | |
| 164 | + "registration-token": { type: "string", desc: "Register this runner with a repository admin's registration token (one repository)", arg: "TOKEN" }, | |
| 165 | + ephemeral: { type: "boolean", desc: "Register for a single job, then exit when it finishes" }, | |
| 166 | + }, | |
| 167 | + maxArgs: 0, | |
| 168 | + async run(ctx, o) { | |
| 169 | + const agent = resolveAgentPath(ctx.io, str(o, "agent")); | |
| 170 | + const repos = list(o, "repo").length ? list(o, "repo").map(parseRepoArg) : [await ctx.repo()]; | |
| 171 | + const labels = (str(o, "labels") ?? "").split(",").map((s) => s.trim()).filter(Boolean); | |
| 172 | + const name = str(o, "name") ?? hostname(); | |
| 173 | + const intervalMs = Math.max(0, Number(str(o, "interval") ?? 5)) * 1000; | |
| 174 | + if (!Number.isFinite(intervalMs)) throw new CliError("--interval must be a number", 2); | |
| 175 | + const cacheHome = ctx.io.env.XDG_CACHE_HOME && path.isAbsolute(ctx.io.env.XDG_CACHE_HOME) ? ctx.io.env.XDG_CACHE_HOME : path.join(ctx.io.env.HOME || homedir(), ".cache"); | |
| 176 | + const workRoot = path.resolve(ctx.io.cwd, str(o, "work-dir") ?? path.join(cacheHome, "diffinery", "runner", name.replace(/[^A-Za-z0-9._-]/g, "_"))); | |
| 177 | + const c = ctx.c; | |
| 178 | + const ephemeral = o.ephemeral === true; | |
| 179 | + const registration = str(o, "registration-token") ?? ctx.io.env.DIFFINERY_RUNNER_REGISTRATION_TOKEN; | |
| 180 | + if (registration && repos.length > 1) throw new CliError("a registration token is for one repository: pass a single -R OWNER/NAME", 2); | |
| 181 | + const url = (await ctx.config()).url; | |
| 182 | + const sources: Source[] = []; | |
| 183 | + for (const repo of repos) { | |
| 184 | + const file = runnerFile(ctx.io.env, url, repo, name); | |
| 185 | + const saved = ephemeral ? null : await readRunner(file); | |
| 186 | + let creds: RunnerCredentials | null = null; | |
| 187 | + if (registration) creds = await register(url, repo, registration, name, labels, ephemeral); | |
| 188 | + else if (saved) { | |
| 189 | + creds = saved; | |
| 190 | + if (labels.length && labels.join(",") !== saved.labels.join(",")) ctx.err(`${c.yellow("warning:")} ${name} was registered with ${repo.owner}/${repo.name} offering [${saved.labels.join(", ")}]; to change its labels, delete ${file} and register it again`); | |
| 191 | + } else { | |
| 192 | + // Signed in: register with the person's token, so jobs are claimed with the runner's own. | |
| 193 | + const user = await ctx.authedApi(); | |
| 194 | + const t = await user.raw<{ token: string }>("POST", `${repoPath(repo)}/actions/runners/registration-token`, { allow: [404, 405] }); | |
| 195 | + if (t.status === 201 && t.data?.token) creds = await register(url, repo, t.data.token, name, labels, ephemeral); | |
| 196 | + else sources.push({ repo, api: user, body: { name, labels } }); // an older server without registration | |
| 197 | + } | |
| 198 | + if (!creds) continue; | |
| 199 | + if (!saved && !ephemeral) { | |
| 200 | + await saveRunner(file, creds); | |
| 201 | + ctx.out(`Registered runner ${c.bold(creds.name)} with ${repo.owner}/${repo.name} ${c.dim(`(saved to ${file})`)}`); | |
| 202 | + } | |
| 203 | + sources.push({ repo, api: new Api(url, creds.token), body: {}, file: ephemeral ? undefined : file }); | |
| 204 | + } | |
| 205 | + | |
| 206 | + let stopping = false; | |
| 207 | + let child: ChildProcess | null = null; | |
| 208 | + const abort = new AbortController(); | |
| 209 | + let signals = 0; | |
| 210 | + const onSignal = () => { | |
| 211 | + signals++; | |
| 212 | + stopping = true; | |
| 213 | + abort.abort(); | |
| 214 | + if (child && child.exitCode === null) { | |
| 215 | + if (signals === 1) { | |
| 216 | + ctx.err(c.yellow("Stopping: asking the running job to cancel (press Ctrl-C again to kill it)…")); | |
| 217 | + child.kill("SIGTERM"); | |
| 218 | + } else child.kill("SIGKILL"); | |
| 219 | + } | |
| 220 | + }; | |
| 221 | + process.on("SIGINT", onSignal); | |
| 222 | + process.on("SIGTERM", onSignal); | |
| 223 | + | |
| 224 | + try { | |
| 225 | + ctx.out(`Runner ${c.bold(name)} offering [${["self-hosted", ...labels].join(", ")}] for ${repos.map((r) => `${r.owner}/${r.name}`).join(", ")}`); | |
| 226 | + if (!o.once) ctx.out(c.dim("Waiting for jobs. Press Ctrl-C to stop.")); | |
| 227 | + let lastError = ""; | |
| 228 | + while (!stopping) { | |
| 229 | + let ran = false; | |
| 230 | + for (const { repo, api, body, file } of sources) { | |
| 231 | + if (stopping) break; | |
| 232 | + let job: ClaimedJob | null; | |
| 233 | + try { | |
| 234 | + job = await claim(api, repo, body); | |
| 235 | + lastError = ""; | |
| 236 | + } catch (e) { | |
| 237 | + if (file && e instanceof ApiError && e.status === 401) throw new CliError(`runner ${name} was removed from ${repo.owner}/${repo.name}; delete ${file} and register it again`); | |
| 238 | + if (o.once || ephemeral || (e instanceof ApiError && [401, 403, 404].includes(e.status))) throw e; | |
| 239 | + const msg = (e as Error).message; | |
| 240 | + if (msg !== lastError) ctx.err(`${c.yellow("warning:")} ${msg} (will retry)`); | |
| 241 | + lastError = msg; | |
| 242 | + continue; | |
| 243 | + } | |
| 244 | + if (!job) continue; | |
| 245 | + ran = true; | |
| 246 | + const code = await runJob(ctx, agent, job, repo, workRoot, !o.quiet, (ch) => (child = ch)); | |
| 247 | + if (o.once || ephemeral) return code; | |
| 248 | + if (stopping) break; | |
| 249 | + } | |
| 250 | + if (o.once) { | |
| 251 | + ctx.out("No jobs waiting."); | |
| 252 | + return 0; | |
| 253 | + } | |
| 254 | + if (!ran && !stopping) await ctx.io.sleep(intervalMs, abort.signal); | |
| 255 | + } | |
| 256 | + ctx.out("Runner stopped."); | |
| 257 | + return 0; | |
| 258 | + } finally { | |
| 259 | + process.off("SIGINT", onSignal); | |
| 260 | + process.off("SIGTERM", onSignal); | |
| 261 | + } | |
| 262 | + }, | |
| 263 | +}; |
addedapps/cli/src/commands/upgrade.ts+43−0View file
| @@ -1,0 +1,43 @@ | ||
| 1 | +import { chmod, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { Api } from "../api.js"; | |
| 4 | +import type { Command } from "../cli.js"; | |
| 5 | +import { CliError } from "../io.js"; | |
| 6 | +import { VERSION } from "../version.js"; | |
| 7 | + | |
| 8 | +const SHEBANG = "#!/usr/bin/env node\n"; | |
| 9 | + | |
| 10 | +/** Replaces the file this CLI runs from with the server's current build (served at /-/cli/diffinery.mjs). */ | |
| 11 | +export const upgradeCommand: Command = { | |
| 12 | + name: "upgrade", | |
| 13 | + summary: "Update the CLI to the server's latest build", | |
| 14 | + description: `Downloads <server>/-/cli/diffinery.mjs and replaces the file this CLI runs from, when it changed. | |
| 15 | +The install script (curl -fsSL https://diffinery.com/install | bash) does the same and sets up PATH.`, | |
| 16 | + maxArgs: 0, | |
| 17 | + async run(ctx) { | |
| 18 | + const cfg = await ctx.config(); | |
| 19 | + if (!ctx.io.selfPath) throw new CliError("can't tell which file this CLI runs from; reinstall with curl -fsSL https://diffinery.com/install | bash"); | |
| 20 | + const self = await realpath(ctx.io.selfPath); | |
| 21 | + const current = await readFile(self, "utf8").catch(() => ""); | |
| 22 | + if (!current.startsWith(SHEBANG)) throw new CliError(`${self} isn't an installed diffinery CLI (running from source?); reinstall with curl -fsSL ${cfg.url}/install | bash`); | |
| 23 | + const res = await new Api(cfg.url, null).raw<string>("GET", "/-/cli/diffinery.mjs", { text: true, headers: { accept: "text/javascript" } }); | |
| 24 | + const next = res.data; | |
| 25 | + if (!next.startsWith(SHEBANG) || next.length < 10_000) throw new CliError(`${cfg.url}/-/cli/diffinery.mjs doesn't look like the CLI; nothing changed`); | |
| 26 | + const version = res.headers.get("x-diffinery-cli-version") ?? VERSION; | |
| 27 | + if (next === current) { | |
| 28 | + ctx.out(`diffinery ${VERSION} is up to date.`); | |
| 29 | + return; | |
| 30 | + } | |
| 31 | + // Write beside it and rename over it, so a failed download never leaves half a file. | |
| 32 | + const tmp = path.join(path.dirname(self), `.diffinery-upgrade-${process.pid}.mjs`); | |
| 33 | + try { | |
| 34 | + await writeFile(tmp, next, { mode: 0o755 }); | |
| 35 | + await chmod(tmp, 0o755); | |
| 36 | + await rename(tmp, self); | |
| 37 | + } catch (e) { | |
| 38 | + await rm(tmp, { force: true }); | |
| 39 | + throw new CliError(`couldn't replace ${self}: ${(e as Error).message}`); | |
| 40 | + } | |
| 41 | + ctx.out(`${ctx.c.green("✓")} Updated diffinery ${VERSION === version ? VERSION : `${VERSION} → ${version}`} (${self})`); | |
| 42 | + }, | |
| 43 | +}; |
addedapps/cli/src/commands/workflow.ts+140−0View file
| @@ -1,0 +1,140 @@ | ||
| 1 | +import { readdir, readFile, stat } from "node:fs/promises"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { isWarning, parseWorkflow } from "@diffinery/workflow-yaml"; | |
| 4 | +import { enc } from "../api.js"; | |
| 5 | +import { list, repoPath, str, type Command, type Group } from "../cli.js"; | |
| 6 | +import { gitCapture } from "../git.js"; | |
| 7 | +import { CliError } from "../io.js"; | |
| 8 | +import { json, table } from "../output.js"; | |
| 9 | +import type { RepoJson } from "./repo.js"; | |
| 10 | + | |
| 11 | +export const WORKFLOW_DIRS = [".github/workflows", ".diffinery/workflows"]; | |
| 12 | +const isYaml = (f: string) => /\.ya?ml$/i.test(f); | |
| 13 | + | |
| 14 | +/** Parses -f key=value pairs into workflow_dispatch inputs. */ | |
| 15 | +export function parseInputs(pairs: string[]): Record<string, string> { | |
| 16 | + const out: Record<string, string> = {}; | |
| 17 | + for (const p of pairs) { | |
| 18 | + const i = p.indexOf("="); | |
| 19 | + if (i <= 0) throw new CliError(`-f expects key=value, got "${p}"`, 2); | |
| 20 | + out[p.slice(0, i)] = p.slice(i + 1); | |
| 21 | + } | |
| 22 | + return out; | |
| 23 | +} | |
| 24 | + | |
| 25 | +const run: Command = { | |
| 26 | + name: "workflow run", | |
| 27 | + summary: "Start a workflow_dispatch run", | |
| 28 | + args: "<workflow-file>", | |
| 29 | + description: "The workflow is matched by path (.github/workflows/deploy.yml) or file name (deploy.yml).", | |
| 30 | + options: { | |
| 31 | + ref: { type: "string", short: "r", desc: "Branch or tag to run on (default: the default branch)", arg: "REF" }, | |
| 32 | + field: { type: "string", short: "f", multiple: true, desc: "Input value", arg: "KEY=VALUE" }, | |
| 33 | + }, | |
| 34 | + repo: true, | |
| 35 | + json: true, | |
| 36 | + minArgs: 1, | |
| 37 | + maxArgs: 1, | |
| 38 | + async run(ctx, o, [file]) { | |
| 39 | + const inputs = parseInputs(list(o, "field")); | |
| 40 | + const repo = await ctx.repo(); | |
| 41 | + const api = await ctx.authedApi(); | |
| 42 | + const ref = str(o, "ref") ?? (await api.get<RepoJson>(repoPath(repo))).default_branch; | |
| 43 | + const wf = file!.replace(/^\.\//, ""); | |
| 44 | + const r = await api.post<{ run_id: string; html_url: string }>(`${repoPath(repo)}/actions/workflows/${enc(wf)}/dispatches`, { ref, inputs }); | |
| 45 | + if (ctx.json) return json(ctx.io, r); | |
| 46 | + ctx.out(`${ctx.c.green("✓")} Started run ${r.run_id} of ${wf} on ${ref}`); | |
| 47 | + ctx.out(r.html_url); | |
| 48 | + ctx.err(ctx.c.dim(`Follow it: diffinery run watch ${r.run_id} -R ${repo.owner}/${repo.name}`)); | |
| 49 | + }, | |
| 50 | +}; | |
| 51 | + | |
| 52 | +const listCmd: Command = { | |
| 53 | + name: "workflow list", | |
| 54 | + summary: "List the repository's workflows", | |
| 55 | + repo: true, | |
| 56 | + json: true, | |
| 57 | + maxArgs: 0, | |
| 58 | + async run(ctx) { | |
| 59 | + const repo = await ctx.repo(); | |
| 60 | + const res = await (await ctx.api()).get<{ workflows: Array<{ name: string; path: string; state: string; errors: string[]; triggers?: string[] }> }>(`${repoPath(repo)}/actions/workflows`); | |
| 61 | + if (ctx.json) return json(ctx.io, res.workflows); | |
| 62 | + if (!res.workflows.length) return ctx.err("No workflows."); | |
| 63 | + const c = ctx.c; | |
| 64 | + ctx.io.stdout.write(table(res.workflows.map((w) => [w.name, c.dim(w.path), w.state === "active" ? c.green(w.state) : c.red(w.state), c.dim((w.triggers ?? []).join(", "))]))); | |
| 65 | + }, | |
| 66 | +}; | |
| 67 | + | |
| 68 | +async function collectFiles(cwd: string, root: string, args: string[]): Promise<string[]> { | |
| 69 | + const out: string[] = []; | |
| 70 | + const addDir = async (dir: string, explicit: boolean) => { | |
| 71 | + let entries: string[]; | |
| 72 | + try { | |
| 73 | + entries = await readdir(dir); | |
| 74 | + } catch (e) { | |
| 75 | + if (!explicit && (e as NodeJS.ErrnoException).code === "ENOENT") return; | |
| 76 | + throw new CliError(`cannot read ${dir}: ${(e as Error).message}`); | |
| 77 | + } | |
| 78 | + for (const f of entries.sort()) if (isYaml(f)) out.push(path.join(dir, f)); | |
| 79 | + }; | |
| 80 | + if (!args.length) { | |
| 81 | + for (const d of WORKFLOW_DIRS) await addDir(path.join(root, d), false); | |
| 82 | + return out; | |
| 83 | + } | |
| 84 | + for (const a of args) { | |
| 85 | + const p = path.resolve(cwd, a); | |
| 86 | + let s; | |
| 87 | + try { | |
| 88 | + s = await stat(p); | |
| 89 | + } catch { | |
| 90 | + throw new CliError(`no such file: ${a}`); | |
| 91 | + } | |
| 92 | + if (s.isDirectory()) await addDir(p, true); | |
| 93 | + else out.push(p); | |
| 94 | + } | |
| 95 | + return out; | |
| 96 | +} | |
| 97 | + | |
| 98 | +const validate: Command = { | |
| 99 | + name: "workflow validate", | |
| 100 | + summary: "Check workflow files for errors", | |
| 101 | + args: "[files or directories...]", | |
| 102 | + description: `Default: every .yml/.yaml file in .github/workflows and .diffinery/workflows of the | |
| 103 | +current repository. Uses the same parser as the server. Exits 1 when any file has errors.`, | |
| 104 | + json: true, | |
| 105 | + async run(ctx, _o, args) { | |
| 106 | + const top = await gitCapture(ctx.io, ["rev-parse", "--show-toplevel"]); | |
| 107 | + const root = top.code === 0 && top.stdout.trim() ? top.stdout.trim() : ctx.io.cwd; | |
| 108 | + const files = await collectFiles(ctx.io.cwd, root, args); | |
| 109 | + if (!files.length) { | |
| 110 | + if (ctx.json) return json(ctx.io, []); | |
| 111 | + ctx.err(`No workflow files found in ${WORKFLOW_DIRS.join(" or ")}.`); | |
| 112 | + return 0; | |
| 113 | + } | |
| 114 | + const c = ctx.c; | |
| 115 | + const results = []; | |
| 116 | + let errors = 0; | |
| 117 | + let warnings = 0; | |
| 118 | + for (const f of files) { | |
| 119 | + const rel = path.relative(ctx.io.cwd, f) || f; | |
| 120 | + const relRoot = path.relative(root, f).split(path.sep).join("/"); | |
| 121 | + const source = await readFile(f, "utf8"); | |
| 122 | + const res = parseWorkflow(source, relRoot.startsWith("..") ? rel : relRoot); | |
| 123 | + const items = res.errors.map((e) => ({ ...e, severity: isWarning(e) ? "warning" : "error", message: isWarning(e) ? e.message.replace(/^warning:\s*/, "") : e.message })); | |
| 124 | + results.push({ file: rel, valid: !items.some((i) => i.severity === "error"), problems: items }); | |
| 125 | + for (const i of items) { | |
| 126 | + if (i.severity === "error") errors++; | |
| 127 | + else warnings++; | |
| 128 | + if (ctx.json) continue; | |
| 129 | + const loc = `${rel}${i.line ? `:${i.line}${i.col ? `:${i.col}` : ""}` : ""}`; | |
| 130 | + ctx.out(`${c.bold(loc)}: ${i.severity === "error" ? c.red("error") : c.yellow("warning")}: ${i.message}${i.path ? c.dim(` (${i.path})`) : ""}`); | |
| 131 | + } | |
| 132 | + if (!ctx.json && !items.length) ctx.out(`${c.green("✓")} ${rel}`); | |
| 133 | + } | |
| 134 | + if (ctx.json) json(ctx.io, results); | |
| 135 | + else ctx.err(`${files.length} file${files.length === 1 ? "" : "s"} checked: ${errors ? c.red(`${errors} error${errors === 1 ? "" : "s"}`) : "no errors"}, ${warnings} warning${warnings === 1 ? "" : "s"}`); | |
| 136 | + return errors ? 1 : 0; | |
| 137 | + }, | |
| 138 | +}; | |
| 139 | + | |
| 140 | +export const workflowGroup: Group = { name: "workflow", summary: "Run and validate workflows", commands: [run, listCmd, validate] }; |
addedapps/cli/src/config.ts+107−0View file
| @@ -1,0 +1,107 @@ | ||
| 1 | +import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; | |
| 2 | +import { homedir } from "node:os"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import { CliError, type Io } from "./io.js"; | |
| 5 | + | |
| 6 | +export const DEFAULT_URL = "https://diffinery.com"; | |
| 7 | + | |
| 8 | +export interface StoredConfig { | |
| 9 | + url?: string; | |
| 10 | + token?: string; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export interface ResolvedConfig { | |
| 14 | + url: string; | |
| 15 | + token: string | null; | |
| 16 | + /** Where the token came from, for messages. */ | |
| 17 | + tokenSource: "env" | "config" | null; | |
| 18 | + urlSource: "env" | "config" | "default"; | |
| 19 | + file: string; | |
| 20 | +} | |
| 21 | + | |
| 22 | +export function configDir(env: NodeJS.ProcessEnv): string { | |
| 23 | + const xdg = env.XDG_CONFIG_HOME; | |
| 24 | + const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(env.HOME || homedir(), ".config"); | |
| 25 | + return path.join(base, "diffinery"); | |
| 26 | +} | |
| 27 | + | |
| 28 | +export const configPath = (env: NodeJS.ProcessEnv) => path.join(configDir(env), "config.json"); | |
| 29 | + | |
| 30 | +export async function readConfig(env: NodeJS.ProcessEnv): Promise<StoredConfig> { | |
| 31 | + const file = configPath(env); | |
| 32 | + let text: string; | |
| 33 | + try { | |
| 34 | + text = await readFile(file, "utf8"); | |
| 35 | + } catch (e) { | |
| 36 | + if ((e as NodeJS.ErrnoException).code === "ENOENT") return {}; | |
| 37 | + throw e; | |
| 38 | + } | |
| 39 | + try { | |
| 40 | + const v = JSON.parse(text) as unknown; | |
| 41 | + if (!v || typeof v !== "object") return {}; | |
| 42 | + const o = v as Record<string, unknown>; | |
| 43 | + return { url: typeof o.url === "string" ? o.url : undefined, token: typeof o.token === "string" ? o.token : undefined }; | |
| 44 | + } catch { | |
| 45 | + throw new CliError(`${file} is not valid JSON; fix or delete it`); | |
| 46 | + } | |
| 47 | +} | |
| 48 | + | |
| 49 | +export async function writeConfig(env: NodeJS.ProcessEnv, cfg: StoredConfig): Promise<string> { | |
| 50 | + const dir = configDir(env); | |
| 51 | + await mkdir(dir, { recursive: true, mode: 0o700 }); | |
| 52 | + // mkdir's mode only applies to a directory it creates: an existing one may be readable by others. | |
| 53 | + await chmod(dir, 0o700); | |
| 54 | + const file = configPath(env); | |
| 55 | + const clean: StoredConfig = {}; | |
| 56 | + if (cfg.url) clean.url = cfg.url; | |
| 57 | + if (cfg.token) clean.token = cfg.token; | |
| 58 | + // A new file that is never readable by others, then renamed over the old one, so the token is never | |
| 59 | + // in a loosely permissioned file, even for a moment, and a crash never leaves half a config. | |
| 60 | + const tmp = path.join(dir, `.config.json.${process.pid}.${Date.now()}.tmp`); | |
| 61 | + try { | |
| 62 | + await writeFile(tmp, JSON.stringify(clean, null, 2) + "\n", { mode: 0o600, flag: "wx" }); | |
| 63 | + await rename(tmp, file); | |
| 64 | + } catch (e) { | |
| 65 | + await rm(tmp, { force: true }); | |
| 66 | + throw e; | |
| 67 | + } | |
| 68 | + return file; | |
| 69 | +} | |
| 70 | + | |
| 71 | +/** localhost, *.localhost, 127.0.0.0/8 and ::1. */ | |
| 72 | +export function isLoopback(hostname: string): boolean { | |
| 73 | + const h = hostname.toLowerCase().replace(/^\[|\]$/g, ""); | |
| 74 | + return h === "localhost" || h.endsWith(".localhost") || h === "::1" || /^127(\.\d{1,3}){3}$/.test(h); | |
| 75 | +} | |
| 76 | + | |
| 77 | +/** Normalizes a user-typed server URL: adds https://, drops trailing slashes and any path. */ | |
| 78 | +export function normalizeUrl(u: string): string { | |
| 79 | + let s = u.trim(); | |
| 80 | + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = `https://${s}`; | |
| 81 | + let parsed: URL; | |
| 82 | + try { | |
| 83 | + parsed = new URL(s); | |
| 84 | + } catch { | |
| 85 | + throw new CliError(`invalid URL: ${u}`); | |
| 86 | + } | |
| 87 | + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new CliError(`URL must be http(s): ${u}`); | |
| 88 | + // Tokens go with every request: plain http only to this machine (a local development server). | |
| 89 | + if (parsed.protocol === "http:" && !isLoopback(parsed.hostname)) throw new CliError(`refusing ${parsed.protocol}//${parsed.host}: tokens would be sent unencrypted; use https (plain http works only for localhost)`); | |
| 90 | + return `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/+$/, "")}`; | |
| 91 | +} | |
| 92 | + | |
| 93 | +/** Env vars override the config file; the URL falls back to the public instance. */ | |
| 94 | +export async function resolveConfig(io: Io): Promise<ResolvedConfig> { | |
| 95 | + const stored = await readConfig(io.env); | |
| 96 | + const envUrl = io.env.DIFFINERY_URL?.trim(); | |
| 97 | + const envToken = io.env.DIFFINERY_TOKEN?.trim(); | |
| 98 | + const url = envUrl ? normalizeUrl(envUrl) : stored.url ? normalizeUrl(stored.url) : DEFAULT_URL; | |
| 99 | + const token = envToken || stored.token || null; | |
| 100 | + return { | |
| 101 | + url, | |
| 102 | + token, | |
| 103 | + tokenSource: envToken ? "env" : stored.token ? "config" : null, | |
| 104 | + urlSource: envUrl ? "env" : stored.url ? "config" : "default", | |
| 105 | + file: configPath(io.env), | |
| 106 | + }; | |
| 107 | +} |
addedapps/cli/src/git.ts+103−0View file
| @@ -1,0 +1,103 @@ | ||
| 1 | +import { spawn } from "node:child_process"; | |
| 2 | +import { CliError, type Io } from "./io.js"; | |
| 3 | + | |
| 4 | +export interface GitResult { | |
| 5 | + code: number; | |
| 6 | + stdout: string; | |
| 7 | + stderr: string; | |
| 8 | +} | |
| 9 | + | |
| 10 | +/** Runs git and captures its output. */ | |
| 11 | +export function gitCapture(io: Io, args: string[], o: { cwd?: string; env?: NodeJS.ProcessEnv; input?: string } = {}): Promise<GitResult> { | |
| 12 | + return new Promise((resolve) => { | |
| 13 | + let child; | |
| 14 | + try { | |
| 15 | + child = spawn("git", args, { cwd: o.cwd ?? io.cwd, env: o.env ?? io.env, stdio: [o.input === undefined ? "ignore" : "pipe", "pipe", "pipe"] }); | |
| 16 | + } catch (e) { | |
| 17 | + return resolve({ code: 127, stdout: "", stderr: String((e as Error).message) }); | |
| 18 | + } | |
| 19 | + let stdout = ""; | |
| 20 | + let stderr = ""; | |
| 21 | + child.stdout?.on("data", (d: Buffer) => (stdout += d.toString())); | |
| 22 | + child.stderr?.on("data", (d: Buffer) => (stderr += d.toString())); | |
| 23 | + child.on("error", (e) => resolve({ code: 127, stdout, stderr: stderr + e.message })); | |
| 24 | + child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr })); | |
| 25 | + if (child.stdin) { | |
| 26 | + child.stdin.on("error", () => {}); // git may exit before reading its input | |
| 27 | + child.stdin.end(o.input); | |
| 28 | + } | |
| 29 | + }); | |
| 30 | +} | |
| 31 | + | |
| 32 | +/** Runs git with output going to the user's terminal (clone/push progress). */ | |
| 33 | +export function gitInteractive(io: Io, args: string[], o: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): Promise<number> { | |
| 34 | + return new Promise((resolve, reject) => { | |
| 35 | + const child = spawn("git", args, { cwd: o.cwd ?? io.cwd, env: o.env ?? io.env, stdio: ["inherit", "pipe", "pipe"] }); | |
| 36 | + child.stdout.on("data", (d: Buffer) => io.stdout.write(d.toString())); | |
| 37 | + child.stderr.on("data", (d: Buffer) => io.stderr.write(d.toString())); | |
| 38 | + child.on("error", (e) => reject(new CliError(`could not run git: ${e.message}`))); | |
| 39 | + child.on("close", (code) => resolve(code ?? 1)); | |
| 40 | + }); | |
| 41 | +} | |
| 42 | + | |
| 43 | +export async function git(io: Io, args: string[], o: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): Promise<string> { | |
| 44 | + const r = await gitCapture(io, args, o); | |
| 45 | + if (r.code !== 0) throw new CliError(`git ${args[0]} failed: ${r.stderr.trim() || `exit ${r.code}`}`); | |
| 46 | + return r.stdout.trim(); | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** | |
| 50 | + * Environment that adds one-off git config for a single git invocation | |
| 51 | + * (GIT_CONFIG_COUNT/KEY/VALUE, git 2.31+). Unlike `-c`, values do not show up | |
| 52 | + * in the process list, and like `-c` they are never written to .git/config. | |
| 53 | + */ | |
| 54 | +export function withGitConfig(env: NodeJS.ProcessEnv, entries: Array<[string, string]>): NodeJS.ProcessEnv { | |
| 55 | + const out = { ...env }; | |
| 56 | + let n = Number(out.GIT_CONFIG_COUNT ?? 0); | |
| 57 | + if (!Number.isInteger(n) || n < 0) n = 0; | |
| 58 | + for (const [k, v] of entries) { | |
| 59 | + out[`GIT_CONFIG_KEY_${n}`] = k; | |
| 60 | + out[`GIT_CONFIG_VALUE_${n}`] = v; | |
| 61 | + n++; | |
| 62 | + } | |
| 63 | + out.GIT_CONFIG_COUNT = String(n); | |
| 64 | + return out; | |
| 65 | +} | |
| 66 | + | |
| 67 | +/** `http.<url>.extraHeader` entry that authenticates git HTTP requests to `baseUrl` only. */ | |
| 68 | +export function basicAuthHeader(baseUrl: string, user: string, password: string): [string, string] { | |
| 69 | + return [`http.${baseUrl.replace(/\/$/, "")}/.extraHeader`, `Authorization: Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`]; | |
| 70 | +} | |
| 71 | + | |
| 72 | +/** Parses owner/name out of a remote URL that points at `host` (https, ssh or scp-like). */ | |
| 73 | +export function parseRemote(remote: string, host: string): { owner: string; name: string } | null { | |
| 74 | + const r = remote.trim(); | |
| 75 | + let h: string; | |
| 76 | + let p: string; | |
| 77 | + const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/\/)(.+)$/.exec(r); | |
| 78 | + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(r)) { | |
| 79 | + try { | |
| 80 | + const u = new URL(r); | |
| 81 | + h = u.host; | |
| 82 | + p = decodeURIComponent(u.pathname); | |
| 83 | + if (u.protocol === "ssh:" && u.port) h = u.hostname; // ssh port differs from the web port | |
| 84 | + } catch { | |
| 85 | + return null; | |
| 86 | + } | |
| 87 | + } else if (scp) { | |
| 88 | + h = scp[1]!; | |
| 89 | + p = scp[2]!; | |
| 90 | + } else return null; | |
| 91 | + const want = host.toLowerCase(); | |
| 92 | + if (h.toLowerCase() !== want && h.toLowerCase() !== want.replace(/:\d+$/, "")) return null; | |
| 93 | + const parts = p.replace(/^\/+/, "").replace(/\/+$/, "").split("/"); | |
| 94 | + if (parts.length !== 2 || !parts[0] || !parts[1]) return null; | |
| 95 | + const name = parts[1].replace(/\.git$/, ""); | |
| 96 | + return name ? { owner: parts[0], name } : null; | |
| 97 | +} | |
| 98 | + | |
| 99 | +export async function currentBranch(io: Io): Promise<string> { | |
| 100 | + const r = await gitCapture(io, ["symbolic-ref", "--quiet", "--short", "HEAD"]); | |
| 101 | + if (r.code !== 0 || !r.stdout.trim()) throw new CliError("could not determine the current branch (detached HEAD or not a git repository); pass --head"); | |
| 102 | + return r.stdout.trim(); | |
| 103 | +} |
addedapps/cli/src/github.ts+127−0View file
| @@ -1,0 +1,127 @@ | ||
| 1 | +import { CliError } from "./io.js"; | |
| 2 | +import { VERSION } from "./version.js"; | |
| 3 | + | |
| 4 | +export class GitHubError extends CliError { | |
| 5 | + constructor( | |
| 6 | + readonly status: number, | |
| 7 | + message: string, | |
| 8 | + ) { | |
| 9 | + super(message); | |
| 10 | + } | |
| 11 | +} | |
| 12 | + | |
| 13 | +export interface GitHubOptions { | |
| 14 | + baseUrl?: string; | |
| 15 | + token?: string | null; | |
| 16 | + sleep: (ms: number) => Promise<void>; | |
| 17 | + log?: (msg: string) => void; | |
| 18 | + /** Longest single wait for a rate-limit reset before giving up (default 1h). */ | |
| 19 | + maxWaitMs?: number; | |
| 20 | + now?: () => number; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** Next-page URL from a GitHub `Link` header. */ | |
| 24 | +export function nextLink(link: string | null): string | null { | |
| 25 | + if (!link) return null; | |
| 26 | + for (const part of link.split(",")) { | |
| 27 | + const m = /<([^>]+)>\s*;\s*rel="?next"?/.exec(part); | |
| 28 | + if (m) return m[1]!; | |
| 29 | + } | |
| 30 | + return null; | |
| 31 | +} | |
| 32 | + | |
| 33 | +/** Minimal GitHub REST client with pagination and rate-limit handling. */ | |
| 34 | +export class GitHub { | |
| 35 | + readonly baseUrl: string; | |
| 36 | + constructor(private o: GitHubOptions) { | |
| 37 | + this.baseUrl = (o.baseUrl ?? "https://api.github.com").replace(/\/+$/, ""); | |
| 38 | + } | |
| 39 | + | |
| 40 | + private async fetchRetrying(url: string): Promise<Response> { | |
| 41 | + const now = this.o.now ?? Date.now; | |
| 42 | + const maxWait = this.o.maxWaitMs ?? 3600_000; | |
| 43 | + for (let attempt = 0; ; attempt++) { | |
| 44 | + const headers: Record<string, string> = { accept: "application/vnd.github+json", "x-github-api-version": "2022-11-28", "user-agent": `diffinery-cli/${VERSION}` }; | |
| 45 | + if (this.o.token) headers.authorization = `Bearer ${this.o.token}`; | |
| 46 | + let res: Response; | |
| 47 | + try { | |
| 48 | + res = await fetch(url, { headers }); | |
| 49 | + } catch (e) { | |
| 50 | + if (attempt < 3) { | |
| 51 | + await this.o.sleep(1000 * 2 ** attempt); | |
| 52 | + continue; | |
| 53 | + } | |
| 54 | + throw new CliError(`could not reach GitHub (${new URL(url).host}): ${(e as Error).message}`); | |
| 55 | + } | |
| 56 | + if ((res.status === 403 || res.status === 429) && attempt < 8) { | |
| 57 | + const retryAfter = res.headers.get("retry-after"); | |
| 58 | + const remaining = res.headers.get("x-ratelimit-remaining"); | |
| 59 | + const reset = res.headers.get("x-ratelimit-reset"); | |
| 60 | + let wait: number | null = null; | |
| 61 | + if (retryAfter && Number.isFinite(Number(retryAfter))) wait = Number(retryAfter) * 1000; | |
| 62 | + else if (remaining === "0" && reset && Number.isFinite(Number(reset))) wait = Math.max(0, Number(reset) * 1000 - now()) + 1000; | |
| 63 | + else if (res.status === 429) wait = 60_000; | |
| 64 | + else { | |
| 65 | + const text = await res.clone().text(); | |
| 66 | + if (/secondary rate limit|abuse/i.test(text)) wait = 60_000; | |
| 67 | + } | |
| 68 | + if (wait !== null) { | |
| 69 | + if (wait > maxWait) throw new GitHubError(res.status, `GitHub rate limit: reset is ${Math.ceil(wait / 60000)} minutes away; try again later${this.o.token ? "" : " or set GH_TOKEN"}`); | |
| 70 | + this.o.log?.(`GitHub rate limit reached; waiting ${Math.ceil(wait / 1000)}s…`); | |
| 71 | + await res.body?.cancel(); | |
| 72 | + await this.o.sleep(wait); | |
| 73 | + continue; | |
| 74 | + } | |
| 75 | + } | |
| 76 | + if (res.status >= 500 && attempt < 3) { | |
| 77 | + await res.body?.cancel(); | |
| 78 | + await this.o.sleep(1000 * 2 ** attempt); | |
| 79 | + continue; | |
| 80 | + } | |
| 81 | + return res; | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + private url(path: string, query?: Record<string, string | number>): string { | |
| 86 | + const u = new URL(/^https?:\/\//.test(path) ? path : `${this.baseUrl}${path}`); | |
| 87 | + for (const [k, v] of Object.entries(query ?? {})) u.searchParams.set(k, String(v)); | |
| 88 | + return u.toString(); | |
| 89 | + } | |
| 90 | + | |
| 91 | + private async check(res: Response, url: string): Promise<void> { | |
| 92 | + if (res.ok) return; | |
| 93 | + let msg = res.statusText; | |
| 94 | + try { | |
| 95 | + const b = (await res.json()) as { message?: string }; | |
| 96 | + if (b.message) msg = b.message; | |
| 97 | + } catch { | |
| 98 | + /* not JSON */ | |
| 99 | + } | |
| 100 | + throw new GitHubError(res.status, `GitHub ${new URL(url).pathname}: ${res.status} ${msg}`); | |
| 101 | + } | |
| 102 | + | |
| 103 | + async get<T>(path: string, query?: Record<string, string | number>): Promise<T> { | |
| 104 | + const url = this.url(path, query); | |
| 105 | + const res = await this.fetchRetrying(url); | |
| 106 | + await this.check(res, url); | |
| 107 | + return (await res.json()) as T; | |
| 108 | + } | |
| 109 | + | |
| 110 | + /** | |
| 111 | + * All pages of a list endpoint. `key` picks the array out of wrapped responses | |
| 112 | + * such as `{ total_count, variables: [...] }`. | |
| 113 | + */ | |
| 114 | + async paginate<T>(path: string, query: Record<string, string | number> = {}, key?: string): Promise<T[]> { | |
| 115 | + const out: T[] = []; | |
| 116 | + let url: string | null = this.url(path, { per_page: 100, ...query }); | |
| 117 | + while (url) { | |
| 118 | + const res = await this.fetchRetrying(url); | |
| 119 | + await this.check(res, url); | |
| 120 | + const body = (await res.json()) as unknown; | |
| 121 | + const items = key ? ((body as Record<string, unknown>)[key] as T[] | undefined) : (body as T[]); | |
| 122 | + if (Array.isArray(items)) out.push(...items); | |
| 123 | + url = nextLink(res.headers.get("link")); | |
| 124 | + } | |
| 125 | + return out; | |
| 126 | + } | |
| 127 | +} |
addedapps/cli/src/io.ts+104−0View file
| @@ -1,0 +1,104 @@ | ||
| 1 | +import type { Readable } from "node:stream"; | |
| 2 | + | |
| 3 | +/** Everything a command touches in the outside world, so tests can run the CLI in-process. */ | |
| 4 | +export interface Io { | |
| 5 | + env: NodeJS.ProcessEnv; | |
| 6 | + cwd: string; | |
| 7 | + stdin: Readable & { isTTY?: boolean }; | |
| 8 | + stdout: { write(s: string): unknown; isTTY?: boolean }; | |
| 9 | + stderr: { write(s: string): unknown; isTTY?: boolean }; | |
| 10 | + /** Overridable for tests (polling loops). */ | |
| 11 | + sleep(ms: number, signal?: AbortSignal): Promise<void>; | |
| 12 | + /** Opens a URL in the browser (tests stand in for the browser); the default runs the system's opener. */ | |
| 13 | + openUrl?(url: string): Promise<boolean>; | |
| 14 | + /** The file this CLI runs from, which `diffinery upgrade` replaces. */ | |
| 15 | + selfPath?: string; | |
| 16 | +} | |
| 17 | + | |
| 18 | +export function sleep(ms: number, signal?: AbortSignal): Promise<void> { | |
| 19 | + return new Promise((resolve) => { | |
| 20 | + if (signal?.aborted) return resolve(); | |
| 21 | + const t = setTimeout(done, ms); | |
| 22 | + function done() { | |
| 23 | + clearTimeout(t); | |
| 24 | + signal?.removeEventListener("abort", done); | |
| 25 | + resolve(); | |
| 26 | + } | |
| 27 | + signal?.addEventListener("abort", done, { once: true }); | |
| 28 | + }); | |
| 29 | +} | |
| 30 | + | |
| 31 | +export function processIo(): Io { | |
| 32 | + return { env: process.env, cwd: process.cwd(), stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, sleep, selfPath: process.argv[1] }; | |
| 33 | +} | |
| 34 | + | |
| 35 | +/** A user-facing failure: printed as `error: message`, exits with `code`. */ | |
| 36 | +export class CliError extends Error { | |
| 37 | + constructor( | |
| 38 | + message: string, | |
| 39 | + readonly code = 1, | |
| 40 | + ) { | |
| 41 | + super(message); | |
| 42 | + } | |
| 43 | +} | |
| 44 | + | |
| 45 | +/** Exit silently with a code (the command already printed what it wanted). */ | |
| 46 | +export class ExitCode extends Error { | |
| 47 | + constructor(readonly code: number) { | |
| 48 | + super(`exit ${code}`); | |
| 49 | + } | |
| 50 | +} | |
| 51 | + | |
| 52 | +export async function readAll(stream: Readable): Promise<string> { | |
| 53 | + const chunks: Buffer[] = []; | |
| 54 | + for await (const c of stream) chunks.push(typeof c === "string" ? Buffer.from(c) : (c as Buffer)); | |
| 55 | + return Buffer.concat(chunks).toString("utf8"); | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** Reads one line from a TTY without echoing it (for tokens and secret values). */ | |
| 59 | +export async function promptHidden(io: Io, question: string): Promise<string> { | |
| 60 | + const stdin = io.stdin as NodeJS.ReadStream; | |
| 61 | + io.stderr.write(question); | |
| 62 | + return new Promise((resolve, reject) => { | |
| 63 | + let value = ""; | |
| 64 | + const raw = typeof stdin.setRawMode === "function"; | |
| 65 | + if (raw) stdin.setRawMode(true); | |
| 66 | + stdin.resume(); | |
| 67 | + stdin.setEncoding("utf8"); | |
| 68 | + const finish = (err?: Error) => { | |
| 69 | + stdin.off("data", onData); | |
| 70 | + if (raw) stdin.setRawMode(false); | |
| 71 | + stdin.pause(); | |
| 72 | + io.stderr.write("\n"); | |
| 73 | + if (err) reject(err); | |
| 74 | + else resolve(value); | |
| 75 | + }; | |
| 76 | + const onData = (chunk: string) => { | |
| 77 | + for (const ch of chunk) { | |
| 78 | + if (ch === "\r" || ch === "\n" || ch === "\u0004") return finish(); | |
| 79 | + if (ch === "\u0003") return finish(new CliError("cancelled", 130)); | |
| 80 | + if (ch === "\u007f" || ch === "\b") value = value.slice(0, -1); | |
| 81 | + else value += ch; | |
| 82 | + } | |
| 83 | + }; | |
| 84 | + stdin.on("data", onData); | |
| 85 | + }); | |
| 86 | +} | |
| 87 | + | |
| 88 | +/** Asks a yes-or-no question on a terminal; anything but y or yes is no. */ | |
| 89 | +export async function promptYesNo(io: Io, question: string): Promise<boolean> { | |
| 90 | + const { createInterface } = await import("node:readline/promises"); | |
| 91 | + const rl = createInterface({ input: io.stdin, output: io.stderr as NodeJS.WritableStream, terminal: false }); | |
| 92 | + try { | |
| 93 | + return /^\s*y(es)?\s*$/i.test(await rl.question(question)); | |
| 94 | + } finally { | |
| 95 | + rl.close(); | |
| 96 | + } | |
| 97 | +} | |
| 98 | + | |
| 99 | +/** Value from a flag, else stdin when piped, else a hidden prompt. */ | |
| 100 | +export async function valueFromFlagOrStdin(io: Io, flag: string | undefined, prompt: string): Promise<string> { | |
| 101 | + if (flag !== undefined) return flag; | |
| 102 | + if (!io.stdin.isTTY) return (await readAll(io.stdin)).replace(/\r?\n$/, ""); | |
| 103 | + return promptHidden(io, prompt); | |
| 104 | +} |
addedapps/cli/src/login.ts+179−0View file
| @@ -1,0 +1,179 @@ | ||
| 1 | +import { spawn } from "node:child_process"; | |
| 2 | +import { createHash, randomBytes } from "node:crypto"; | |
| 3 | +import { createServer, type Server } from "node:http"; | |
| 4 | +import type { AddressInfo } from "node:net"; | |
| 5 | +import { arch, hostname, platform } from "node:os"; | |
| 6 | +import { Api } from "./api.js"; | |
| 7 | +import type { Ctx } from "./cli.js"; | |
| 8 | +import { CliError, type Io } from "./io.js"; | |
| 9 | +import { VERSION } from "./version.js"; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Signing in through the browser, so nobody copies a token by hand (the server side is | |
| 13 | + * apps/diffinery/src/auth/cli-login.ts): | |
| 14 | + * | |
| 15 | + * - browserLogin: listen on 127.0.0.1, open /login/cli with a PKCE challenge, and wait for the | |
| 16 | + * browser to come back to /callback with a one-time code; trade it, with the verifier only this | |
| 17 | + * process knows, for a token. | |
| 18 | + * - deviceLogin: for machines without a browser. Print a short code to type on /login/device in | |
| 19 | + * any browser, and poll until the person approves. | |
| 20 | + */ | |
| 21 | + | |
| 22 | +export type Scope = "read" | "write" | "admin"; | |
| 23 | + | |
| 24 | +export interface TokenGrant { | |
| 25 | + access_token: string; | |
| 26 | + scope: Scope; | |
| 27 | + expires_at: string; | |
| 28 | + login: string; | |
| 29 | +} | |
| 30 | + | |
| 31 | +interface TokenError { | |
| 32 | + error: string; | |
| 33 | + error_description?: string; | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** How long to wait for the person before giving up. */ | |
| 37 | +const BROWSER_TIMEOUT_MS = 10 * 60_000; | |
| 38 | + | |
| 39 | +const b64url = (b: Buffer) => b.toString("base64url"); | |
| 40 | + | |
| 41 | +/** What the authorize page shows about this computer, and what the token is named after. */ | |
| 42 | +export function clientInfo(): { host: string; os: string; version: string } { | |
| 43 | + return { host: hostname().replace(/\.local$/, "") || "unknown host", os: `${platform()}-${arch()}`, version: VERSION }; | |
| 44 | +} | |
| 45 | + | |
| 46 | +/** | |
| 47 | + * Opens `url` in the default browser: `BROWSER` when set (`BROWSER=none` opens nothing), else | |
| 48 | + * open (macOS), rundll32 (Windows, which avoids cmd's handling of `&`) or xdg-open. True when a | |
| 49 | + * browser command started; the caller prints the URL either way. | |
| 50 | + */ | |
| 51 | +export function openBrowser(io: Io, url: string): Promise<boolean> { | |
| 52 | + if (io.openUrl) return io.openUrl(url); | |
| 53 | + const pref = io.env.BROWSER?.trim(); | |
| 54 | + if (pref === "none") return Promise.resolve(false); | |
| 55 | + const [cmd, args] = pref ? [pref, [url]] : process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]]; | |
| 56 | + return new Promise((resolve) => { | |
| 57 | + try { | |
| 58 | + const child = spawn(cmd, args, { stdio: "ignore", detached: true }); | |
| 59 | + child.once("error", () => resolve(false)); | |
| 60 | + child.once("spawn", () => { | |
| 61 | + child.unref(); | |
| 62 | + resolve(true); | |
| 63 | + }); | |
| 64 | + } catch { | |
| 65 | + resolve(false); | |
| 66 | + } | |
| 67 | + }); | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** Whether a browser on this machine can reach a server on this machine's 127.0.0.1. */ | |
| 71 | +export function browserLikely(io: Io): boolean { | |
| 72 | + if (io.openUrl) return true; | |
| 73 | + if (io.env.BROWSER?.trim() && io.env.BROWSER.trim() !== "none") return true; | |
| 74 | + // Over SSH the browser runs on another computer, where 127.0.0.1 is not this one. | |
| 75 | + if (io.env.SSH_CONNECTION || io.env.SSH_TTY) return false; | |
| 76 | + if (process.platform === "linux" || process.platform === "freebsd" || process.platform === "openbsd") return !!(io.env.DISPLAY || io.env.WAYLAND_DISPLAY); | |
| 77 | + return true; | |
| 78 | +} | |
| 79 | + | |
| 80 | +async function exchange(url: string, body: Record<string, string>): Promise<TokenGrant | TokenError> { | |
| 81 | + const res = await new Api(url, null).raw<TokenGrant | TokenError>("POST", "/-/cli/token", { body, allow: [400] }); | |
| 82 | + return res.data; | |
| 83 | +} | |
| 84 | + | |
| 85 | +const isGrant = (v: TokenGrant | TokenError): v is TokenGrant => typeof (v as TokenGrant).access_token === "string"; | |
| 86 | + | |
| 87 | +const page = (title: string, text: string) => | |
| 88 | + `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font:16px system-ui,sans-serif;max-width:32rem;margin:15vh auto;padding:0 1rem"><h1 style="font-size:1.4rem">${title}</h1><p>${text}</p></body>`; | |
| 89 | + | |
| 90 | +export async function browserLogin(ctx: Ctx, url: string, scope: Scope): Promise<TokenGrant> { | |
| 91 | + const verifier = b64url(randomBytes(32)); | |
| 92 | + const challenge = b64url(createHash("sha256").update(verifier).digest()); | |
| 93 | + const state = b64url(randomBytes(24)); | |
| 94 | + let settle!: { ok: (g: TokenGrant) => void; fail: (e: Error) => void }; | |
| 95 | + const done = new Promise<TokenGrant>((ok, fail) => (settle = { ok, fail })); | |
| 96 | + done.catch(() => {}); // awaited below; this only keeps an early failure from counting as unhandled | |
| 97 | + let busy = false; | |
| 98 | + const server: Server = createServer(async (req, res) => { | |
| 99 | + const u = new URL(req.url ?? "/", "http://127.0.0.1"); | |
| 100 | + if (req.method !== "GET" || u.pathname !== "/callback") { | |
| 101 | + res.writeHead(404, { "content-type": "text/plain" }).end("not found"); | |
| 102 | + return; | |
| 103 | + } | |
| 104 | + // Only the browser this process sent out carries its state; anything else is ignored. | |
| 105 | + if (u.searchParams.get("state") !== state || busy) { | |
| 106 | + res.writeHead(400, { "content-type": "text/html; charset=utf-8" }).end(page("Not this sign-in", "This link doesn't belong to the <code>diffinery login</code> that is waiting. Run it again if you need to.")); | |
| 107 | + return; | |
| 108 | + } | |
| 109 | + busy = true; | |
| 110 | + const back = (result: string) => res.writeHead(302, { location: `${url}/login/cli/done?result=${result}`, "cache-control": "no-store" }).end(); | |
| 111 | + const error = u.searchParams.get("error"); | |
| 112 | + if (error) { | |
| 113 | + back(error === "access_denied" ? "denied" : "failed"); | |
| 114 | + return settle.fail(new CliError(error === "access_denied" ? "the sign-in was cancelled in the browser" : `the browser returned an error: ${error}`)); | |
| 115 | + } | |
| 116 | + try { | |
| 117 | + const got = await exchange(url, { grant_type: "authorization_code", code: u.searchParams.get("code") ?? "", code_verifier: verifier }); | |
| 118 | + if (!isGrant(got)) throw new CliError(`sign-in failed: ${got.error_description ?? got.error}`); | |
| 119 | + back("ok"); | |
| 120 | + settle.ok(got); | |
| 121 | + } catch (e) { | |
| 122 | + back("failed"); | |
| 123 | + settle.fail(e instanceof Error ? e : new Error(String(e))); | |
| 124 | + } | |
| 125 | + }); | |
| 126 | + await new Promise<void>((ok, fail) => { | |
| 127 | + server.once("error", fail); | |
| 128 | + server.listen(0, "127.0.0.1", () => ok()); | |
| 129 | + }); | |
| 130 | + const port = (server.address() as AddressInfo).port; | |
| 131 | + const client = clientInfo(); | |
| 132 | + const authorize = `${url}/login/cli?${new URLSearchParams({ port: String(port), state, code_challenge: challenge, code_challenge_method: "S256", scope, host: client.host, os: client.os, version: client.version })}`; | |
| 133 | + const timer = setTimeout(() => settle.fail(new CliError("timed out waiting for the browser; run `diffinery login` again")), BROWSER_TIMEOUT_MS); | |
| 134 | + try { | |
| 135 | + const opened = await openBrowser(ctx.io, authorize); | |
| 136 | + ctx.err(opened ? `Opening your browser to sign in to ${ctx.c.bold(new URL(url).host)}…` : `Open this link in a browser on this computer to sign in:`); | |
| 137 | + ctx.err(opened ? ctx.c.dim(`If it didn't open, visit:\n${authorize}`) : authorize); | |
| 138 | + ctx.err(ctx.c.dim("Waiting for you to authorize (Ctrl+C to cancel). No browser here? Run `diffinery login --device`.")); | |
| 139 | + return await done; | |
| 140 | + } finally { | |
| 141 | + clearTimeout(timer); | |
| 142 | + server.closeAllConnections(); | |
| 143 | + server.close(); | |
| 144 | + } | |
| 145 | +} | |
| 146 | + | |
| 147 | +export async function deviceLogin(ctx: Ctx, url: string, scope: Scope): Promise<TokenGrant> { | |
| 148 | + const api = new Api(url, null); | |
| 149 | + const d = await api.post<{ device_code: string; user_code: string; verification_uri: string; expires_in: number; interval: number }>("/-/cli/device", { client: clientInfo(), scope }); | |
| 150 | + ctx.err(`Your one-time code: ${ctx.c.bold(d.user_code)}`); | |
| 151 | + ctx.err(`Open ${ctx.c.bold(d.verification_uri)} in a browser on any device, sign in, and enter the code.`); | |
| 152 | + ctx.err(ctx.c.dim("Waiting for you to approve (Ctrl+C to cancel)…")); | |
| 153 | + let interval = Math.max(1, d.interval) * 1000; | |
| 154 | + const end = Date.now() + d.expires_in * 1000; | |
| 155 | + while (Date.now() < end) { | |
| 156 | + await ctx.io.sleep(interval); | |
| 157 | + const got = await exchange(url, { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: d.device_code }); | |
| 158 | + if (isGrant(got)) return got; | |
| 159 | + if (got.error === "authorization_pending") continue; | |
| 160 | + if (got.error === "slow_down") { | |
| 161 | + interval += 5000; | |
| 162 | + continue; | |
| 163 | + } | |
| 164 | + if (got.error === "access_denied") throw new CliError("the sign-in was cancelled in the browser"); | |
| 165 | + if (got.error === "expired_token") break; | |
| 166 | + throw new CliError(`sign-in failed: ${got.error_description ?? got.error}`); | |
| 167 | + } | |
| 168 | + throw new CliError("the code expired; run `diffinery login --device` again"); | |
| 169 | +} | |
| 170 | + | |
| 171 | +/** `diffinery logout`: a token a CLI sign-in created is revoked with itself. False for any other token, or when the server can't be reached. */ | |
| 172 | +export async function revokeCliToken(url: string, token: string): Promise<boolean> { | |
| 173 | + try { | |
| 174 | + const r = await new Api(url, token).raw<{ revoked?: boolean }>("DELETE", "/-/cli/token", { allow: [401, 403, 404] }); | |
| 175 | + return r.status === 200 && r.data?.revoked === true; | |
| 176 | + } catch { | |
| 177 | + return false; | |
| 178 | + } | |
| 179 | +} |
addedapps/cli/src/main.ts+31−0View file
| @@ -1,0 +1,31 @@ | ||
| 1 | +import { dispatch, type Registry } from "./cli.js"; | |
| 2 | +import { adminCommands, agentGroup, secretGroup, tokenGroup, variableGroup } from "./commands/admin.js"; | |
| 3 | +import { authCommands, authGroup } from "./commands/auth.js"; | |
| 4 | +import { issueGroup } from "./commands/issue.js"; | |
| 5 | +import { migrateGroup } from "./commands/migrate.js"; | |
| 6 | +import { prGroup } from "./commands/pr.js"; | |
| 7 | +import { repoGroup } from "./commands/repo.js"; | |
| 8 | +import { runGroup } from "./commands/run.js"; | |
| 9 | +import { runnerCommand } from "./commands/runner.js"; | |
| 10 | +import { upgradeCommand } from "./commands/upgrade.js"; | |
| 11 | +import { workflowGroup } from "./commands/workflow.js"; | |
| 12 | +import { CliError, processIo, type Io } from "./io.js"; | |
| 13 | + | |
| 14 | +export const registry: Registry = { | |
| 15 | + groups: [authGroup, repoGroup, prGroup, issueGroup, runGroup, workflowGroup, secretGroup, variableGroup, tokenGroup, agentGroup, migrateGroup], | |
| 16 | + commands: [...authCommands, ...adminCommands, runnerCommand, upgradeCommand], | |
| 17 | +}; | |
| 18 | + | |
| 19 | +/** Runs the CLI and returns its exit code (never calls process.exit). */ | |
| 20 | +export async function main(argv: string[], io: Io = processIo()): Promise<number> { | |
| 21 | + try { | |
| 22 | + return await dispatch(registry, argv, io); | |
| 23 | + } catch (e) { | |
| 24 | + if (e instanceof CliError) { | |
| 25 | + io.stderr.write(`error: ${e.message}\n`); | |
| 26 | + return e.code; | |
| 27 | + } | |
| 28 | + io.stderr.write(`error: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}\n`); | |
| 29 | + return 1; | |
| 30 | + } | |
| 31 | +} |
addedapps/cli/src/output.ts+67−0View file
| @@ -1,0 +1,67 @@ | ||
| 1 | +import type { Io } from "./io.js"; | |
| 2 | + | |
| 3 | +export interface Colors { | |
| 4 | + enabled: boolean; | |
| 5 | + bold(s: string): string; | |
| 6 | + dim(s: string): string; | |
| 7 | + red(s: string): string; | |
| 8 | + green(s: string): string; | |
| 9 | + yellow(s: string): string; | |
| 10 | + cyan(s: string): string; | |
| 11 | + magenta(s: string): string; | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function colors(io: Io): Colors { | |
| 15 | + const on = !!io.stdout.isTTY && io.env.NO_COLOR === undefined && io.env.TERM !== "dumb"; | |
| 16 | + const wrap = (open: number, close: number) => (s: string) => (on ? `\x1b[${open}m${s}\x1b[${close}m` : s); | |
| 17 | + return { enabled: on, bold: wrap(1, 22), dim: wrap(2, 22), red: wrap(31, 39), green: wrap(32, 39), yellow: wrap(33, 39), cyan: wrap(36, 39), magenta: wrap(35, 39) }; | |
| 18 | +} | |
| 19 | + | |
| 20 | +const ANSI = /\x1b\[[0-9;]*m/g; | |
| 21 | +const width = (s: string) => [...s.replace(ANSI, "")].length; | |
| 22 | + | |
| 23 | +/** Left-aligned columns separated by two spaces; the last column is not padded. */ | |
| 24 | +export function table(rows: string[][]): string { | |
| 25 | + if (!rows.length) return ""; | |
| 26 | + const cols = Math.max(...rows.map((r) => r.length)); | |
| 27 | + const widths = Array.from({ length: cols }, (_, i) => Math.max(...rows.map((r) => width(r[i] ?? "")))); | |
| 28 | + return rows.map((r) => r.map((cell, i) => (i === r.length - 1 ? cell : cell + " ".repeat(widths[i]! - width(cell)))).join(" ").trimEnd()).join("\n") + "\n"; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export function json(io: Io, v: unknown): void { | |
| 32 | + io.stdout.write(JSON.stringify(v, null, 2) + "\n"); | |
| 33 | +} | |
| 34 | + | |
| 35 | +export function truncate(s: string | null | undefined, n: number): string { | |
| 36 | + const t = (s ?? "").replace(/\s+/g, " ").trim(); | |
| 37 | + return t.length > n ? `${t.slice(0, n - 1)}…` : t; | |
| 38 | +} | |
| 39 | + | |
| 40 | +export function ago(iso: string | null | undefined, now = Date.now()): string { | |
| 41 | + if (!iso) return ""; | |
| 42 | + const t = Date.parse(iso); | |
| 43 | + if (Number.isNaN(t)) return iso; | |
| 44 | + const s = Math.max(0, Math.round((now - t) / 1000)); | |
| 45 | + if (s < 60) return `${s}s ago`; | |
| 46 | + if (s < 3600) return `${Math.floor(s / 60)}m ago`; | |
| 47 | + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; | |
| 48 | + if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`; | |
| 49 | + return iso.slice(0, 10); | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** Colors a status/conclusion word. */ | |
| 53 | +export function statusText(c: Colors, status: string | null | undefined, conclusion?: string | null): string { | |
| 54 | + if (status && status !== "completed") return c.yellow(status.replace(/_/g, " ")); | |
| 55 | + const v = conclusion ?? status ?? "unknown"; | |
| 56 | + if (v === "success" || v === "merged" || v === "open" || v === "clean") return c.green(v); | |
| 57 | + if (v === "failure" || v === "timed_out" || v === "cancelled" || v === "action_required") return c.red(v.replace(/_/g, " ")); | |
| 58 | + if (v === "closed" || v === "skipped" || v === "neutral") return c.dim(v); | |
| 59 | + return c.yellow(v.replace(/_/g, " ")); | |
| 60 | +} | |
| 61 | + | |
| 62 | +export function symbol(c: Colors, status: string | null | undefined, conclusion?: string | null): string { | |
| 63 | + if (status && status !== "completed") return c.yellow("*"); | |
| 64 | + if (conclusion === "success") return c.green("✓"); | |
| 65 | + if (conclusion === "skipped" || conclusion === "neutral") return c.dim("-"); | |
| 66 | + return c.red("X"); | |
| 67 | +} |
addedapps/cli/src/version.ts+1−0View file
| @@ -1,0 +1,1 @@ | ||
| 1 | +export const VERSION = "0.1.0"; |
addedapps/cli/test/config.test.ts+132−0View file
| @@ -1,0 +1,132 @@ | ||
| 1 | +import { chmodSync, mkdirSync, readdirSync, statSync, writeFileSync, readFileSync } from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 4 | +import { configPath, normalizeUrl, resolveConfig, writeConfig } from "../src/config.js"; | |
| 5 | +import type { Io } from "../src/io.js"; | |
| 6 | +import { fakeServer, isolatedEnv, run, type FakeServer } from "./helpers.js"; | |
| 7 | + | |
| 8 | +const ioWith = (env: NodeJS.ProcessEnv) => ({ env }) as unknown as Io; | |
| 9 | + | |
| 10 | +describe("config", () => { | |
| 11 | + it("defaults to https://diffinery.com with no token", async () => { | |
| 12 | + const cfg = await resolveConfig(ioWith(isolatedEnv())); | |
| 13 | + expect(cfg).toMatchObject({ url: "https://diffinery.com", token: null, urlSource: "default", tokenSource: null }); | |
| 14 | + }); | |
| 15 | + | |
| 16 | + it("reads the file under XDG_CONFIG_HOME and lets env vars override it", async () => { | |
| 17 | + const env = isolatedEnv(); | |
| 18 | + const file = path.join(env.XDG_CONFIG_HOME!, "diffinery", "config.json"); | |
| 19 | + expect(configPath(env)).toBe(file); | |
| 20 | + mkdirSync(path.dirname(file), { recursive: true }); | |
| 21 | + writeFileSync(file, JSON.stringify({ url: "https://forge.example.com/", token: "dfy_file" })); | |
| 22 | + | |
| 23 | + expect(await resolveConfig(ioWith(env))).toMatchObject({ url: "https://forge.example.com", token: "dfy_file", urlSource: "config", tokenSource: "config" }); | |
| 24 | + expect(await resolveConfig(ioWith({ ...env, DIFFINERY_TOKEN: "dfy_env" }))).toMatchObject({ url: "https://forge.example.com", token: "dfy_env", tokenSource: "env" }); | |
| 25 | + expect(await resolveConfig(ioWith({ ...env, DIFFINERY_URL: "other.example.com" }))).toMatchObject({ url: "https://other.example.com", token: "dfy_file", urlSource: "env" }); | |
| 26 | + }); | |
| 27 | + | |
| 28 | + it("falls back to ~/.config when XDG_CONFIG_HOME is unset", () => { | |
| 29 | + const env = isolatedEnv(); | |
| 30 | + delete env.XDG_CONFIG_HOME; | |
| 31 | + expect(configPath(env)).toBe(path.join(env.HOME!, ".config", "diffinery", "config.json")); | |
| 32 | + }); | |
| 33 | + | |
| 34 | + it("writes the config with mode 0600", async () => { | |
| 35 | + const env = isolatedEnv(); | |
| 36 | + const file = await writeConfig(env, { url: "https://a.example", token: "dfy_x" }); | |
| 37 | + expect(statSync(file).mode & 0o777).toBe(0o600); | |
| 38 | + expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: "https://a.example", token: "dfy_x" }); | |
| 39 | + }); | |
| 40 | + | |
| 41 | + it("tightens an existing loose config directory and file, and leaves no temporary file", async () => { | |
| 42 | + const env = isolatedEnv(); | |
| 43 | + const file = configPath(env); | |
| 44 | + mkdirSync(path.dirname(file), { recursive: true, mode: 0o755 }); | |
| 45 | + chmodSync(path.dirname(file), 0o755); | |
| 46 | + writeFileSync(file, "{}", { mode: 0o644 }); | |
| 47 | + chmodSync(file, 0o644); | |
| 48 | + await writeConfig(env, { url: "https://a.example", token: "dfy_y" }); | |
| 49 | + expect(statSync(path.dirname(file)).mode & 0o777).toBe(0o700); | |
| 50 | + expect(statSync(file).mode & 0o777).toBe(0o600); | |
| 51 | + expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: "https://a.example", token: "dfy_y" }); | |
| 52 | + expect(readdirSync(path.dirname(file))).toEqual(["config.json"]); | |
| 53 | + }); | |
| 54 | + | |
| 55 | + it("normalizes URLs", () => { | |
| 56 | + expect(normalizeUrl("forge.example.com/")).toBe("https://forge.example.com"); | |
| 57 | + expect(normalizeUrl("http://localhost:8787")).toBe("http://localhost:8787"); | |
| 58 | + expect(normalizeUrl("http://127.0.0.1:8787")).toBe("http://127.0.0.1:8787"); | |
| 59 | + expect(normalizeUrl("http://[::1]:8787")).toBe("http://[::1]:8787"); | |
| 60 | + expect(normalizeUrl("http://dev.localhost")).toBe("http://dev.localhost"); | |
| 61 | + expect(() => normalizeUrl("ftp://x")).toThrow(); | |
| 62 | + }); | |
| 63 | + | |
| 64 | + it("refuses plain http anywhere but this machine, since tokens go with every request", () => { | |
| 65 | + expect(() => normalizeUrl("http://forge.example.com")).toThrow(/unencrypted/); | |
| 66 | + expect(() => normalizeUrl("http://10.0.0.5:8787")).toThrow(/unencrypted/); | |
| 67 | + expect(() => normalizeUrl("http://localhost.example.com")).toThrow(/unencrypted/); | |
| 68 | + }); | |
| 69 | +}); | |
| 70 | + | |
| 71 | +describe("login / whoami / logout", () => { | |
| 72 | + let srv: FakeServer; | |
| 73 | + beforeEach(async () => { | |
| 74 | + srv = await fakeServer(); | |
| 75 | + srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_good" ? { body: { login: "alice", name: "Alice", type: "User" } } : { status: 401, body: { message: "bad credentials" } })); | |
| 76 | + }); | |
| 77 | + afterEach(() => srv.close()); | |
| 78 | + | |
| 79 | + it("verifies the token, saves it 0600, and whoami uses it", async () => { | |
| 80 | + const env = isolatedEnv(); | |
| 81 | + const r = await run(["login", "--url", srv.url, "--token", "dfy_good"], { env }); | |
| 82 | + expect(r.code).toBe(0); | |
| 83 | + expect(r.stdout).toContain("Logged in to"); | |
| 84 | + expect(r.stdout).toContain("alice"); | |
| 85 | + expect(r.stdout).not.toContain("dfy_good"); | |
| 86 | + const file = configPath(env); | |
| 87 | + expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: srv.url, token: "dfy_good" }); | |
| 88 | + expect(statSync(file).mode & 0o777).toBe(0o600); | |
| 89 | + | |
| 90 | + const who = await run(["whoami"], { env }); | |
| 91 | + expect(who.code).toBe(0); | |
| 92 | + expect(who.stdout).toContain("alice"); | |
| 93 | + expect(srv.find("GET", "/api/v3/user").at(-1)!.headers.authorization).toBe("Bearer dfy_good"); | |
| 94 | + | |
| 95 | + const out = await run(["logout"], { env }); | |
| 96 | + expect(out.code).toBe(0); | |
| 97 | + expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: srv.url }); | |
| 98 | + expect((await run(["whoami"], { env })).code).toBe(1); | |
| 99 | + }); | |
| 100 | + | |
| 101 | + it("reads the token from stdin when it is not a TTY", async () => { | |
| 102 | + const env = isolatedEnv(); | |
| 103 | + const r = await run(["login", "--url", srv.url], { env, stdin: "dfy_good\n" }); | |
| 104 | + expect(r.code).toBe(0); | |
| 105 | + expect(JSON.parse(readFileSync(configPath(env), "utf8")).token).toBe("dfy_good"); | |
| 106 | + }); | |
| 107 | + | |
| 108 | + it("refuses a token the server rejects and saves nothing", async () => { | |
| 109 | + const env = isolatedEnv(); | |
| 110 | + const r = await run(["login", "--url", srv.url, "--token", "dfy_bad"], { env }); | |
| 111 | + expect(r.code).toBe(1); | |
| 112 | + expect(r.stderr).toContain("bad credentials"); | |
| 113 | + expect(() => readFileSync(configPath(env))).toThrow(); | |
| 114 | + }); | |
| 115 | + | |
| 116 | + it("DIFFINERY_TOKEN/DIFFINERY_URL override the saved config", async () => { | |
| 117 | + const env = isolatedEnv(); | |
| 118 | + await writeConfig(env, { url: "http://127.0.0.1:1", token: "dfy_saved" }); | |
| 119 | + const r = await run(["whoami", "--json"], { env: { ...env, DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_good" } }); | |
| 120 | + expect(r.code).toBe(0); | |
| 121 | + expect(JSON.parse(r.stdout).login).toBe("alice"); | |
| 122 | + }); | |
| 123 | + | |
| 124 | + it("mcp-config prints a snippet without the token unless asked", async () => { | |
| 125 | + const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_good" }); | |
| 126 | + const r = await run(["mcp-config"], { env }); | |
| 127 | + const cfg = JSON.parse(r.stdout); | |
| 128 | + expect(cfg.mcpServers.diffinery).toEqual({ type: "http", url: `${srv.url}/mcp`, headers: { Authorization: "Bearer ${DIFFINERY_TOKEN}" } }); | |
| 129 | + const shown = JSON.parse((await run(["mcp-config", "--show-token"], { env })).stdout); | |
| 130 | + expect(shown.mcpServers.diffinery.headers.Authorization).toBe("Bearer dfy_good"); | |
| 131 | + }); | |
| 132 | +}); |
addedapps/cli/test/credential.test.ts+76−0View file
| @@ -1,0 +1,76 @@ | ||
| 1 | +import { execFileSync } from "node:child_process"; | |
| 2 | +import { chmodSync, readFileSync, writeFileSync } from "node:fs"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import { describe, expect, it } from "vitest"; | |
| 5 | +import { parseCredentialInput } from "../src/commands/auth.js"; | |
| 6 | +import { isolatedEnv, run, tempDir } from "./helpers.js"; | |
| 7 | + | |
| 8 | +describe("git credential helper", () => { | |
| 9 | + const env = isolatedEnv({ DIFFINERY_URL: "https://forge.example.com", DIFFINERY_TOKEN: "dfy_secret" }); | |
| 10 | + | |
| 11 | + it("parses key=value lines up to the blank line", () => { | |
| 12 | + expect(parseCredentialInput("protocol=https\nhost=a.b\npath=x/y.git\n\nignored=1\n")).toEqual({ protocol: "https", host: "a.b", path: "x/y.git" }); | |
| 13 | + }); | |
| 14 | + | |
| 15 | + it("answers get for the configured host", async () => { | |
| 16 | + const r = await run(["auth", "git-credential", "get"], { env, stdin: "protocol=https\nhost=forge.example.com\n\n" }); | |
| 17 | + expect(r.code).toBe(0); | |
| 18 | + expect(r.stdout).toBe("protocol=https\nhost=forge.example.com\nusername=x-token\npassword=dfy_secret\n"); | |
| 19 | + }); | |
| 20 | + | |
| 21 | + it("stays silent for other hosts, other protocols and other operations", async () => { | |
| 22 | + for (const stdin of ["protocol=https\nhost=github.com\n\n", "protocol=http\nhost=forge.example.com\n\n"]) { | |
| 23 | + const r = await run(["auth", "git-credential", "get"], { env, stdin }); | |
| 24 | + expect(r.code).toBe(0); | |
| 25 | + expect(r.stdout).toBe(""); | |
| 26 | + } | |
| 27 | + for (const op of ["store", "erase"]) { | |
| 28 | + const r = await run(["auth", "git-credential", op], { env, stdin: "protocol=https\nhost=forge.example.com\nusername=x\npassword=y\n\n" }); | |
| 29 | + expect(r.code).toBe(0); | |
| 30 | + expect(r.stdout).toBe(""); | |
| 31 | + } | |
| 32 | + }); | |
| 33 | + | |
| 34 | + it("matches hosts with ports", async () => { | |
| 35 | + const e = { ...env, DIFFINERY_URL: "http://127.0.0.1:8787" }; | |
| 36 | + const r = await run(["auth", "git-credential", "get"], { env: e, stdin: "protocol=http\nhost=127.0.0.1:8787\n" }); | |
| 37 | + expect(r.stdout).toContain("password=dfy_secret"); | |
| 38 | + }); | |
| 39 | + | |
| 40 | + it("prints nothing without a token", async () => { | |
| 41 | + const e = { ...env }; | |
| 42 | + delete e.DIFFINERY_TOKEN; | |
| 43 | + const r = await run(["auth", "git-credential", "get"], { env: e, stdin: "protocol=https\nhost=forge.example.com\n" }); | |
| 44 | + expect(r.stdout).toBe(""); | |
| 45 | + }); | |
| 46 | + | |
| 47 | + it("auth setup-git configures a URL-scoped helper in the global git config", async () => { | |
| 48 | + const r = await run(["auth", "setup-git"], { env }); | |
| 49 | + expect(r.code).toBe(0); | |
| 50 | + const helpers = execFileSync("git", ["config", "--global", "--get-all", "credential.https://forge.example.com.helper"], { env }).toString().split("\n"); | |
| 51 | + expect(helpers.slice(0, 2)).toEqual(["", "!diffinery auth git-credential"]); | |
| 52 | + // Idempotent. | |
| 53 | + await run(["auth", "setup-git"], { env }); | |
| 54 | + expect(execFileSync("git", ["config", "--global", "--get-all", "credential.https://forge.example.com.helper"], { env }).toString().split("\n").slice(0, -1)).toEqual(["", "!diffinery auth git-credential"]); | |
| 55 | + }); | |
| 56 | + | |
| 57 | + it("auth setup-gh signs gh in to the host with the token on stdin, never in its arguments", async () => { | |
| 58 | + const bin = tempDir("diffinery-fake-gh-"); | |
| 59 | + const seen = path.join(bin, "seen"); | |
| 60 | + writeFileSync(path.join(bin, "gh"), `#!/bin/sh\nprintf '%s ' "$@" > "${seen}.args"\ncat > "${seen}.stdin"\n`); | |
| 61 | + chmodSync(path.join(bin, "gh"), 0o755); | |
| 62 | + const e = { ...env, PATH: `${bin}${path.delimiter}${env.PATH}` }; | |
| 63 | + const r = await run(["auth", "setup-gh"], { env: e }); | |
| 64 | + expect(r.code).toBe(0); | |
| 65 | + expect(r.stdout).toContain("gh is signed in to forge.example.com"); | |
| 66 | + expect(r.stdout).not.toContain("dfy_secret"); | |
| 67 | + expect(readFileSync(`${seen}.args`, "utf8").trim()).toBe("auth login --hostname forge.example.com --with-token --git-protocol https"); | |
| 68 | + expect(readFileSync(`${seen}.stdin`, "utf8")).toBe("dfy_secret\n"); | |
| 69 | + }); | |
| 70 | + | |
| 71 | + it("auth setup-gh refuses plain HTTP, which gh doesn't speak", async () => { | |
| 72 | + const r = await run(["auth", "setup-gh"], { env: { ...env, DIFFINERY_URL: "http://127.0.0.1:8787" } }); | |
| 73 | + expect(r.code).not.toBe(0); | |
| 74 | + expect(r.stderr).toContain("HTTPS"); | |
| 75 | + }); | |
| 76 | +}); |
addedapps/cli/test/helpers.ts+213−0View file
| @@ -1,0 +1,213 @@ | ||
| 1 | +import { execFileSync, spawn } from "node:child_process"; | |
| 2 | +import { mkdtempSync, writeFileSync } from "node:fs"; | |
| 3 | +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; | |
| 4 | +import type { AddressInfo } from "node:net"; | |
| 5 | +import { tmpdir } from "node:os"; | |
| 6 | +import path from "node:path"; | |
| 7 | +import { Readable } from "node:stream"; | |
| 8 | +import type { Io } from "../src/io.js"; | |
| 9 | +import { main } from "../src/main.js"; | |
| 10 | + | |
| 11 | +export interface Recorded { | |
| 12 | + method: string; | |
| 13 | + path: string; | |
| 14 | + query: Record<string, string>; | |
| 15 | + headers: IncomingMessage["headers"]; | |
| 16 | + body: unknown; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export interface Reply { | |
| 20 | + status?: number; | |
| 21 | + body?: unknown; | |
| 22 | + headers?: Record<string, string>; | |
| 23 | +} | |
| 24 | + | |
| 25 | +type Handler = (req: Recorded) => Reply | Promise<Reply>; | |
| 26 | + | |
| 27 | +export interface FakeServer { | |
| 28 | + url: string; | |
| 29 | + host: string; | |
| 30 | + requests: Recorded[]; | |
| 31 | + on(method: string, path: string | RegExp, h: Handler | Reply): void; | |
| 32 | + find(method: string, path: string | RegExp): Recorded[]; | |
| 33 | + close(): Promise<void>; | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * A tiny programmable HTTP server: unmatched routes return 404 { message }. | |
| 38 | + * With `gitRoot`, paths containing `.git/` are served by `git http-backend` from that directory. | |
| 39 | + */ | |
| 40 | +export async function fakeServer(o: { gitRoot?: string; gitAuth?: string } = {}): Promise<FakeServer> { | |
| 41 | + const routes: Array<{ method: string; path: string | RegExp; h: Handler }> = []; | |
| 42 | + const requests: Recorded[] = []; | |
| 43 | + const matches = (p: string | RegExp, path: string) => (typeof p === "string" ? p === path : p.test(path)); | |
| 44 | + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { | |
| 45 | + if (o.gitRoot && /\.git\//.test(req.url ?? "")) return gitBackend(o.gitRoot, o.gitAuth, req, res, requests); | |
| 46 | + const chunks: Buffer[] = []; | |
| 47 | + for await (const c of req) chunks.push(c as Buffer); | |
| 48 | + const text = Buffer.concat(chunks).toString("utf8"); | |
| 49 | + let body: unknown = text || undefined; | |
| 50 | + try { | |
| 51 | + if (text) body = JSON.parse(text); | |
| 52 | + } catch { | |
| 53 | + /* keep text */ | |
| 54 | + } | |
| 55 | + const u = new URL(req.url ?? "/", "http://x"); | |
| 56 | + const rec: Recorded = { method: req.method ?? "GET", path: u.pathname, query: Object.fromEntries(u.searchParams), headers: req.headers, body }; | |
| 57 | + requests.push(rec); | |
| 58 | + const route = [...routes].reverse().find((r) => r.method === rec.method && matches(r.path, rec.path)); | |
| 59 | + const reply: Reply = route ? await route.h(rec) : { status: 404, body: { message: "not found" } }; | |
| 60 | + const status = reply.status ?? 200; | |
| 61 | + const headers: Record<string, string> = { ...reply.headers }; | |
| 62 | + let out = ""; | |
| 63 | + if (reply.body !== undefined && status !== 204) { | |
| 64 | + if (typeof reply.body === "string") { | |
| 65 | + out = reply.body; | |
| 66 | + headers["content-type"] ??= "text/plain"; | |
| 67 | + } else { | |
| 68 | + out = JSON.stringify(reply.body); | |
| 69 | + headers["content-type"] ??= "application/json"; | |
| 70 | + } | |
| 71 | + } | |
| 72 | + res.writeHead(status, headers); | |
| 73 | + res.end(out); | |
| 74 | + }); | |
| 75 | + await new Promise<void>((r) => server.listen(0, "127.0.0.1", r)); | |
| 76 | + const port = (server.address() as AddressInfo).port; | |
| 77 | + return { | |
| 78 | + url: `http://127.0.0.1:${port}`, | |
| 79 | + host: `127.0.0.1:${port}`, | |
| 80 | + requests, | |
| 81 | + on(method, path, h) { | |
| 82 | + routes.push({ method, path, h: typeof h === "function" ? h : () => h }); | |
| 83 | + }, | |
| 84 | + find(method, path) { | |
| 85 | + return requests.filter((r) => r.method === method && matches(path, r.path)); | |
| 86 | + }, | |
| 87 | + close: () => | |
| 88 | + new Promise<void>((r) => { | |
| 89 | + server.closeAllConnections(); | |
| 90 | + server.close(() => r()); | |
| 91 | + }), | |
| 92 | + }; | |
| 93 | +} | |
| 94 | + | |
| 95 | +export function tempDir(prefix = "diffinery-cli-"): string { | |
| 96 | + return mkdtempSync(path.join(tmpdir(), prefix)); | |
| 97 | +} | |
| 98 | + | |
| 99 | +export interface RunResult { | |
| 100 | + code: number; | |
| 101 | + stdout: string; | |
| 102 | + stderr: string; | |
| 103 | +} | |
| 104 | + | |
| 105 | +export interface RunOptions { | |
| 106 | + env?: NodeJS.ProcessEnv; | |
| 107 | + cwd?: string; | |
| 108 | + stdin?: string; | |
| 109 | + stdinTTY?: boolean; | |
| 110 | + /** Makes stdout look like a terminal (set NO_COLOR in env to keep the output plain). */ | |
| 111 | + stdoutTTY?: boolean; | |
| 112 | + /** Stands in for the browser when the CLI opens one. */ | |
| 113 | + openUrl?: (url: string) => Promise<boolean>; | |
| 114 | + selfPath?: string; | |
| 115 | +} | |
| 116 | + | |
| 117 | +/** An isolated environment: HOME/XDG in a temp dir, git with no global config. */ | |
| 118 | +export function isolatedEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { | |
| 119 | + const home = tempDir("diffinery-home-"); | |
| 120 | + const gitconfig = path.join(home, ".gitconfig"); | |
| 121 | + writeFileSync(gitconfig, ""); | |
| 122 | + return { | |
| 123 | + PATH: process.env.PATH, | |
| 124 | + HOME: home, | |
| 125 | + XDG_CONFIG_HOME: path.join(home, ".config"), | |
| 126 | + GIT_CONFIG_GLOBAL: gitconfig, | |
| 127 | + GIT_CONFIG_NOSYSTEM: "1", | |
| 128 | + GIT_AUTHOR_NAME: "Test", | |
| 129 | + GIT_AUTHOR_EMAIL: "test@example.com", | |
| 130 | + GIT_COMMITTER_NAME: "Test", | |
| 131 | + GIT_COMMITTER_EMAIL: "test@example.com", | |
| 132 | + // This machine's Claude Code and Codex stay out of it; the tests that want them turn them back on. | |
| 133 | + DIFFINERY_NO_CLAUDE_PLUGINS: "1", | |
| 134 | + DIFFINERY_NO_CODEX: "1", | |
| 135 | + ...extra, | |
| 136 | + }; | |
| 137 | +} | |
| 138 | + | |
| 139 | +export async function run(argv: string[], o: RunOptions = {}): Promise<RunResult> { | |
| 140 | + let stdout = ""; | |
| 141 | + let stderr = ""; | |
| 142 | + const stdin = Readable.from(o.stdin !== undefined ? [Buffer.from(o.stdin)] : []) as Io["stdin"]; | |
| 143 | + stdin.isTTY = o.stdinTTY ?? false; | |
| 144 | + const io: Io = { | |
| 145 | + env: o.env ?? isolatedEnv(), | |
| 146 | + cwd: o.cwd ?? tempDir(), | |
| 147 | + stdin, | |
| 148 | + stdout: { write: (s: string) => ((stdout += s), true), isTTY: o.stdoutTTY }, | |
| 149 | + stderr: { write: (s: string) => ((stderr += s), true) }, | |
| 150 | + sleep: () => new Promise((r) => setTimeout(r, 1)), | |
| 151 | + openUrl: o.openUrl, | |
| 152 | + selfPath: o.selfPath, | |
| 153 | + }; | |
| 154 | + const code = await main(argv, io); | |
| 155 | + return { code, stdout, stderr }; | |
| 156 | +} | |
| 157 | + | |
| 158 | +/** Creates a git repository whose origin points at `remote`, on branch `branch`. */ | |
| 159 | +export function gitRepo(env: NodeJS.ProcessEnv, remote: string, branch = "feature/x"): string { | |
| 160 | + const dir = tempDir("diffinery-repo-"); | |
| 161 | + const g = (...args: string[]) => execFileSync("git", args, { cwd: dir, env, stdio: "pipe" }); | |
| 162 | + g("init", "-q", "-b", "main"); | |
| 163 | + g("remote", "add", "origin", remote); | |
| 164 | + g("commit", "-q", "--allow-empty", "-m", "init"); | |
| 165 | + g("checkout", "-q", "-b", branch); | |
| 166 | + return dir; | |
| 167 | +} | |
| 168 | + | |
| 169 | +/** Serves git smart HTTP via `git http-backend` (CGI), requiring `auth` as the Authorization header when set. */ | |
| 170 | +async function gitBackend(root: string, auth: string | undefined, req: IncomingMessage, res: ServerResponse, requests: Recorded[]): Promise<void> { | |
| 171 | + const u = new URL(req.url ?? "/", "http://x"); | |
| 172 | + requests.push({ method: req.method ?? "GET", path: u.pathname, query: Object.fromEntries(u.searchParams), headers: req.headers, body: undefined }); | |
| 173 | + if (auth && req.headers.authorization !== auth) { | |
| 174 | + res.writeHead(401, { "www-authenticate": 'Basic realm="test"' }); | |
| 175 | + return void res.end(); | |
| 176 | + } | |
| 177 | + const child = spawn("git", ["http-backend"], { | |
| 178 | + env: { | |
| 179 | + PATH: process.env.PATH, | |
| 180 | + GIT_PROJECT_ROOT: root, | |
| 181 | + GIT_HTTP_EXPORT_ALL: "1", | |
| 182 | + GIT_CONFIG_NOSYSTEM: "1", | |
| 183 | + GIT_CONFIG_GLOBAL: "/dev/null", | |
| 184 | + REQUEST_METHOD: req.method ?? "GET", | |
| 185 | + PATH_INFO: decodeURIComponent(u.pathname), | |
| 186 | + QUERY_STRING: u.search.slice(1), | |
| 187 | + CONTENT_TYPE: req.headers["content-type"] ?? "", | |
| 188 | + HTTP_CONTENT_ENCODING: String(req.headers["content-encoding"] ?? ""), | |
| 189 | + GIT_PROTOCOL: String(req.headers["git-protocol"] ?? ""), | |
| 190 | + REMOTE_USER: "tester", | |
| 191 | + REMOTE_ADDR: "127.0.0.1", | |
| 192 | + }, | |
| 193 | + stdio: ["pipe", "pipe", "inherit"], | |
| 194 | + }); | |
| 195 | + req.pipe(child.stdin); | |
| 196 | + const chunks: Buffer[] = []; | |
| 197 | + for await (const c of child.stdout) chunks.push(c as Buffer); | |
| 198 | + const out = Buffer.concat(chunks); | |
| 199 | + const sep = out.indexOf("\r\n\r\n"); | |
| 200 | + const head = out.subarray(0, sep).toString(); | |
| 201 | + const headers: Record<string, string> = {}; | |
| 202 | + let status = 200; | |
| 203 | + for (const line of head.split("\r\n")) { | |
| 204 | + const i = line.indexOf(":"); | |
| 205 | + if (i < 0) continue; | |
| 206 | + const k = line.slice(0, i).trim(); | |
| 207 | + const v = line.slice(i + 1).trim(); | |
| 208 | + if (k.toLowerCase() === "status") status = Number(v.split(" ")[0]); | |
| 209 | + else headers[k] = v; | |
| 210 | + } | |
| 211 | + res.writeHead(status, headers); | |
| 212 | + res.end(out.subarray(sep + 4)); | |
| 213 | +} |
addedapps/cli/test/login.test.ts+217−0View file
| @@ -1,0 +1,217 @@ | ||
| 1 | +import { createHash } from "node:crypto"; | |
| 2 | +import { readFileSync, statSync, writeFileSync } from "node:fs"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 5 | +import { configPath, writeConfig } from "../src/config.js"; | |
| 6 | +import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; | |
| 7 | + | |
| 8 | +/** The CLI's side of signing in through the browser; the server's side is tested in apps/diffinery. */ | |
| 9 | +describe("diffinery login in the browser", () => { | |
| 10 | + let srv: FakeServer; | |
| 11 | + beforeEach(async () => { | |
| 12 | + srv = await fakeServer(); | |
| 13 | + srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_from_browser" ? { body: { login: "alice", type: "User" } } : { status: 401, body: { message: "bad credentials" } })); | |
| 14 | + }); | |
| 15 | + afterEach(() => srv.close()); | |
| 16 | + | |
| 17 | + /** A browser that approves: Diffinery redirects to the CLI's callback with a code and the state it was given. */ | |
| 18 | + const approvingBrowser = (seen: { authorize?: URL; landing?: string }) => async (url: string) => { | |
| 19 | + const u = new URL(url); | |
| 20 | + seen.authorize = u; | |
| 21 | + const callback = `http://127.0.0.1:${u.searchParams.get("port")}/callback?code=the-code&state=${u.searchParams.get("state")}`; | |
| 22 | + // A stray request with the wrong state is ignored, and the real one still works afterwards. | |
| 23 | + expect((await fetch(callback.replace(/state=[^&]+/, "state=forged"), { redirect: "manual" })).status).toBe(400); | |
| 24 | + const res = await fetch(callback, { redirect: "manual" }); | |
| 25 | + seen.landing = res.headers.get("location") ?? ""; | |
| 26 | + return true; | |
| 27 | + }; | |
| 28 | + | |
| 29 | + it("opens the authorize page with a PKCE challenge, trades the code with the verifier, and saves the token", async () => { | |
| 30 | + let verifier = ""; | |
| 31 | + srv.on("POST", "/-/cli/token", (r) => { | |
| 32 | + const b = r.body as Record<string, string>; | |
| 33 | + verifier = b.code_verifier!; | |
| 34 | + return b.grant_type === "authorization_code" && b.code === "the-code" ? { body: { access_token: "dfy_from_browser", token_type: "bearer", scope: "write", expires_at: "2027-01-12T00:00:00Z", login: "alice" } } : { status: 400, body: { error: "invalid_grant" } }; | |
| 35 | + }); | |
| 36 | + const env = isolatedEnv(); | |
| 37 | + const seen: { authorize?: URL; landing?: string } = {}; | |
| 38 | + const r = await run(["login", "--url", srv.url], { env, stdinTTY: true, openUrl: approvingBrowser(seen) }); | |
| 39 | + expect(r.stderr).not.toContain("error"); | |
| 40 | + expect(r.code).toBe(0); | |
| 41 | + const a = seen.authorize!; | |
| 42 | + expect(`${a.origin}${a.pathname}`).toBe(`${srv.url}/login/cli`); | |
| 43 | + expect(a.searchParams.get("code_challenge_method")).toBe("S256"); | |
| 44 | + expect(a.searchParams.get("scope")).toBe("write"); | |
| 45 | + expect(a.searchParams.get("host")).toBeTruthy(); | |
| 46 | + // The verifier never went to the browser; only its hash did. | |
| 47 | + expect(a.searchParams.get("code_challenge")).toBe(createHash("sha256").update(verifier).digest("base64url")); | |
| 48 | + expect(url(a)).not.toContain(verifier); | |
| 49 | + expect(seen.landing).toBe(`${srv.url}/login/cli/done?result=ok`); | |
| 50 | + expect(r.stdout).toContain("Logged in to"); | |
| 51 | + expect(r.stdout).toContain("alice"); | |
| 52 | + expect(r.stdout).toContain("with write access until"); | |
| 53 | + expect(r.stdout).not.toContain("dfy_from_browser"); | |
| 54 | + expect(r.stderr).not.toContain("dfy_from_browser"); | |
| 55 | + expect(JSON.parse(readFileSync(configPath(env), "utf8"))).toEqual({ url: srv.url, token: "dfy_from_browser" }); | |
| 56 | + expect(statSync(configPath(env)).mode & 0o777).toBe(0o600); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it("reports a cancelled sign-in and saves nothing", async () => { | |
| 60 | + const env = isolatedEnv(); | |
| 61 | + const r = await run(["login", "--url", srv.url, "--web", "--scope", "admin"], { | |
| 62 | + env, | |
| 63 | + openUrl: async (url) => { | |
| 64 | + const u = new URL(url); | |
| 65 | + expect(u.searchParams.get("scope")).toBe("admin"); | |
| 66 | + const res = await fetch(`http://127.0.0.1:${u.searchParams.get("port")}/callback?error=access_denied&state=${u.searchParams.get("state")}`, { redirect: "manual" }); | |
| 67 | + expect(res.headers.get("location")).toBe(`${srv.url}/login/cli/done?result=denied`); | |
| 68 | + return true; | |
| 69 | + }, | |
| 70 | + }); | |
| 71 | + expect(r.code).toBe(1); | |
| 72 | + expect(r.stderr).toContain("cancelled in the browser"); | |
| 73 | + expect(() => readFileSync(configPath(env))).toThrow(); | |
| 74 | + }); | |
| 75 | + | |
| 76 | + it("prints the link when no browser opens", async () => { | |
| 77 | + srv.on("POST", "/-/cli/token", { body: { access_token: "dfy_from_browser", scope: "write", expires_at: "2027-01-12T00:00:00Z", login: "alice" } }); | |
| 78 | + const r = await run(["login", "--url", srv.url, "--web"], { | |
| 79 | + openUrl: async (url) => { | |
| 80 | + const u = new URL(url); | |
| 81 | + // The person copies the link into a browser themselves. | |
| 82 | + setTimeout(() => void fetch(`http://127.0.0.1:${u.searchParams.get("port")}/callback?code=c&state=${u.searchParams.get("state")}`, { redirect: "manual" }), 20); | |
| 83 | + return false; | |
| 84 | + }, | |
| 85 | + }); | |
| 86 | + expect(r.code).toBe(0); | |
| 87 | + expect(r.stderr).toContain("Open this link in a browser on this computer"); | |
| 88 | + expect(r.stderr).toContain(`${srv.url}/login/cli?port=`); | |
| 89 | + }); | |
| 90 | + | |
| 91 | + it("refuses an unknown scope", async () => { | |
| 92 | + const r = await run(["login", "--url", srv.url, "--scope", "root"], { stdinTTY: true }); | |
| 93 | + expect(r.code).toBe(2); | |
| 94 | + }); | |
| 95 | +}); | |
| 96 | + | |
| 97 | +const url = (u: URL) => u.toString(); | |
| 98 | + | |
| 99 | +describe("diffinery login --device", () => { | |
| 100 | + let srv: FakeServer; | |
| 101 | + beforeEach(async () => { | |
| 102 | + srv = await fakeServer(); | |
| 103 | + srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_from_device" ? { body: { login: "alice", type: "User" } } : { status: 401, body: { message: "bad credentials" } })); | |
| 104 | + srv.on("POST", "/-/cli/device", { body: { device_code: "secret-device-code", user_code: "BCDF-GHJK", verification_uri: `${"https://diffinery.example"}/login/device`, expires_in: 600, interval: 5 } }); | |
| 105 | + }); | |
| 106 | + afterEach(() => srv.close()); | |
| 107 | + | |
| 108 | + it("prints the code and polls until the person approves", async () => { | |
| 109 | + let polls = 0; | |
| 110 | + srv.on("POST", "/-/cli/token", (r) => { | |
| 111 | + expect(r.body).toEqual({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: "secret-device-code" }); | |
| 112 | + polls++; | |
| 113 | + if (polls === 1) return { status: 400, body: { error: "authorization_pending" } }; | |
| 114 | + if (polls === 2) return { status: 400, body: { error: "slow_down" } }; | |
| 115 | + return { body: { access_token: "dfy_from_device", scope: "read", expires_at: "2027-01-12T00:00:00Z", login: "alice" } }; | |
| 116 | + }); | |
| 117 | + const env = isolatedEnv(); | |
| 118 | + const r = await run(["login", "--url", srv.url, "--device", "--scope", "read"], { env }); | |
| 119 | + expect(r.code).toBe(0); | |
| 120 | + expect(r.stderr).toContain("BCDF-GHJK"); | |
| 121 | + expect(r.stderr).toContain("https://diffinery.example/login/device"); | |
| 122 | + expect(r.stderr).not.toContain("secret-device-code"); | |
| 123 | + expect((srv.find("POST", "/-/cli/device")[0]!.body as { scope: string; client: { host: string } }).scope).toBe("read"); | |
| 124 | + expect(polls).toBe(3); | |
| 125 | + expect(JSON.parse(readFileSync(configPath(env), "utf8")).token).toBe("dfy_from_device"); | |
| 126 | + }); | |
| 127 | + | |
| 128 | + it("is what a terminal without a display gets", async () => { | |
| 129 | + srv.on("POST", "/-/cli/token", { body: { access_token: "dfy_from_device", scope: "write", expires_at: "2027-01-12T00:00:00Z", login: "alice" } }); | |
| 130 | + const r = await run(["login", "--url", srv.url], { env: isolatedEnv({ SSH_CONNECTION: "10.0.0.1 5000 10.0.0.2 22" }), stdinTTY: true }); | |
| 131 | + expect(r.code).toBe(0); | |
| 132 | + expect(r.stderr).toContain("BCDF-GHJK"); | |
| 133 | + }); | |
| 134 | + | |
| 135 | + it("stops when the person cancels", async () => { | |
| 136 | + srv.on("POST", "/-/cli/token", { status: 400, body: { error: "access_denied" } }); | |
| 137 | + const r = await run(["login", "--url", srv.url, "--device"]); | |
| 138 | + expect(r.code).toBe(1); | |
| 139 | + expect(r.stderr).toContain("cancelled in the browser"); | |
| 140 | + }); | |
| 141 | +}); | |
| 142 | + | |
| 143 | +describe("diffinery logout", () => { | |
| 144 | + let srv: FakeServer; | |
| 145 | + beforeEach(async () => { | |
| 146 | + srv = await fakeServer(); | |
| 147 | + }); | |
| 148 | + afterEach(() => srv.close()); | |
| 149 | + | |
| 150 | + it("revokes a CLI sign-in's token on the server, then forgets it", async () => { | |
| 151 | + srv.on("DELETE", "/-/cli/token", { body: { revoked: true } }); | |
| 152 | + const env = isolatedEnv(); | |
| 153 | + await writeConfig(env, { url: srv.url, token: "dfy_cli" }); | |
| 154 | + const r = await run(["logout"], { env }); | |
| 155 | + expect(r.code).toBe(0); | |
| 156 | + expect(r.stdout).toContain("revoked the CLI's token"); | |
| 157 | + expect(srv.find("DELETE", "/-/cli/token")[0]!.headers.authorization).toBe("Bearer dfy_cli"); | |
| 158 | + expect(JSON.parse(readFileSync(configPath(env), "utf8"))).toEqual({ url: srv.url }); | |
| 159 | + }); | |
| 160 | + | |
| 161 | + it("still signs out locally when the server can't revoke it", async () => { | |
| 162 | + srv.on("DELETE", "/-/cli/token", { status: 401, body: { message: "invalid, expired or revoked token" } }); | |
| 163 | + const env = isolatedEnv(); | |
| 164 | + await writeConfig(env, { url: srv.url, token: "dfy_old" }); | |
| 165 | + const r = await run(["logout"], { env }); | |
| 166 | + expect(r.code).toBe(0); | |
| 167 | + expect(r.stdout).toBe(`Logged out of ${srv.url}.\n`); | |
| 168 | + }); | |
| 169 | + | |
| 170 | + it("a new sign-in revokes the CLI token it replaces", async () => { | |
| 171 | + srv.on("DELETE", "/-/cli/token", { body: { revoked: true } }); | |
| 172 | + srv.on("GET", "/api/v3/user", { body: { login: "alice" } }); | |
| 173 | + const env = isolatedEnv(); | |
| 174 | + await writeConfig(env, { url: srv.url, token: "dfy_previous" }); | |
| 175 | + const r = await run(["login", "--url", srv.url, "--token", "dfy_next"], { env }); | |
| 176 | + expect(r.code).toBe(0); | |
| 177 | + expect(srv.find("DELETE", "/-/cli/token").map((q) => q.headers.authorization)).toEqual(["Bearer dfy_previous"]); | |
| 178 | + }); | |
| 179 | +}); | |
| 180 | + | |
| 181 | +describe("diffinery upgrade", () => { | |
| 182 | + let srv: FakeServer; | |
| 183 | + const build = (n: number) => `#!/usr/bin/env node\n// build ${n}\n${"x".repeat(20_000)}\n`; | |
| 184 | + beforeEach(async () => { | |
| 185 | + srv = await fakeServer(); | |
| 186 | + }); | |
| 187 | + afterEach(() => srv.close()); | |
| 188 | + | |
| 189 | + it("replaces the file it runs from when the server has a new build, and says when it's current", async () => { | |
| 190 | + srv.on("GET", "/-/cli/diffinery.mjs", { body: build(2), headers: { "content-type": "text/javascript", "x-diffinery-cli-version": "0.2.0" } }); | |
| 191 | + const self = path.join(tempDir(), "diffinery.mjs"); | |
| 192 | + writeFileSync(self, build(1), { mode: 0o755 }); | |
| 193 | + const env = isolatedEnv({ DIFFINERY_URL: srv.url }); | |
| 194 | + const r = await run(["upgrade"], { env, selfPath: self }); | |
| 195 | + expect(r.stderr).toBe(""); | |
| 196 | + expect(r.code).toBe(0); | |
| 197 | + expect(r.stdout).toContain("Updated diffinery"); | |
| 198 | + expect(readFileSync(self, "utf8")).toBe(build(2)); | |
| 199 | + expect(statSync(self).mode & 0o777).toBe(0o755); | |
| 200 | + const again = await run(["upgrade"], { env, selfPath: self }); | |
| 201 | + expect(again.stdout).toContain("is up to date"); | |
| 202 | + }); | |
| 203 | + | |
| 204 | + it("refuses to replace a source checkout or with something that isn't the CLI", async () => { | |
| 205 | + srv.on("GET", "/-/cli/diffinery.mjs", { body: "<html>maintenance</html>", headers: { "content-type": "text/html" } }); | |
| 206 | + const env = isolatedEnv({ DIFFINERY_URL: srv.url }); | |
| 207 | + const src = path.join(tempDir(), "bin.ts"); | |
| 208 | + writeFileSync(src, "import x from 'y';\n"); | |
| 209 | + expect((await run(["upgrade"], { env, selfPath: src })).stderr).toContain("isn't an installed diffinery CLI"); | |
| 210 | + const self = path.join(tempDir(), "diffinery.mjs"); | |
| 211 | + writeFileSync(self, build(1)); | |
| 212 | + const r = await run(["upgrade"], { env, selfPath: self }); | |
| 213 | + expect(r.code).toBe(1); | |
| 214 | + expect(r.stderr).toContain("doesn't look like the CLI"); | |
| 215 | + expect(readFileSync(self, "utf8")).toBe(build(1)); | |
| 216 | + }); | |
| 217 | +}); |
addedapps/cli/test/migrate.test.ts+188−0View file
| @@ -1,0 +1,188 @@ | ||
| 1 | +import { execFileSync } from "node:child_process"; | |
| 2 | +import { mkdirSync } from "node:fs"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 5 | +import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; | |
| 6 | + | |
| 7 | +describe("migrate github", () => { | |
| 8 | + let srv: FakeServer; | |
| 9 | + let env: NodeJS.ProcessEnv; | |
| 10 | + let gitRoot: string; | |
| 11 | + let srcBare: string; | |
| 12 | + | |
| 13 | + beforeEach(async () => { | |
| 14 | + env = isolatedEnv(); | |
| 15 | + const g = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, env, stdio: "pipe" }).toString().trim(); | |
| 16 | + // Source repository standing in for GitHub: main, develop, a tag and a pull ref. | |
| 17 | + const work = tempDir("diffinery-src-"); | |
| 18 | + g(work, "init", "-q", "-b", "main"); | |
| 19 | + g(work, "commit", "-q", "--allow-empty", "-m", "one"); | |
| 20 | + g(work, "tag", "v1"); | |
| 21 | + g(work, "checkout", "-q", "-b", "develop"); | |
| 22 | + g(work, "commit", "-q", "--allow-empty", "-m", "two"); | |
| 23 | + srcBare = path.join(tempDir("diffinery-gh-"), "widgets.git"); | |
| 24 | + g(work, "clone", "-q", "--bare", work, srcBare); | |
| 25 | + g(srcBare, "update-ref", "refs/pull/2/head", g(work, "rev-parse", "HEAD")); | |
| 26 | + // Target: an empty bare repository served over smart HTTP. | |
| 27 | + gitRoot = tempDir("diffinery-git-"); | |
| 28 | + mkdirSync(path.join(gitRoot, "alice")); | |
| 29 | + g(path.join(gitRoot, "alice"), "init", "-q", "--bare", "widgets.git"); | |
| 30 | + | |
| 31 | + srv = await fakeServer({ gitRoot, gitAuth: `Basic ${Buffer.from("x-token:dfy_t").toString("base64")}` }); | |
| 32 | + const now = "2024-05-01T10:00:00Z"; | |
| 33 | + const ghRepo = { full_name: "gh-org/widgets", name: "widgets", private: false, visibility: "public", description: "Widgets!", default_branch: "develop", clone_url: srcBare, html_url: "https://github.com/gh-org/widgets" }; | |
| 34 | + srv.on("GET", "/gh/repos/gh-org/widgets", { body: ghRepo }); | |
| 35 | + srv.on("GET", "/gh/repos/gh-org/widgets/actions/variables", { body: { total_count: 1, variables: [{ name: "REGION", value: "eu-west" }] } }); | |
| 36 | + srv.on("GET", "/gh/repos/gh-org/widgets/actions/secrets", { body: { total_count: 1, secrets: [{ name: "NPM_TOKEN" }] } }); | |
| 37 | + srv.on("GET", "/gh/repos/gh-org/widgets/labels", { body: [{ name: "bug", color: "d73a4a", description: "Broken" }] }); | |
| 38 | + srv.on("GET", "/gh/repos/gh-org/widgets/pulls", { | |
| 39 | + body: [ | |
| 40 | + { number: 2, title: "Develop work", body: "PR body", draft: false, user: { login: "carol" }, created_at: now, head: { ref: "develop", repo: { full_name: "gh-org/widgets" } }, base: { ref: "main" } }, | |
| 41 | + { number: 4, title: "From a fork", body: null, user: { login: "mallory" }, created_at: now, head: { ref: "patch-1", repo: { full_name: "mallory/widgets" } }, base: { ref: "main" } }, | |
| 42 | + ], | |
| 43 | + }); | |
| 44 | + srv.on("GET", "/gh/repos/gh-org/widgets/issues", { | |
| 45 | + body: [ | |
| 46 | + { number: 5, title: "Open issue", body: "five", state: "open", user: { login: "dave" }, labels: [], comments: 0, created_at: now }, | |
| 47 | + { number: 1, title: "Old bug", body: "it breaks", state: "closed", state_reason: "not_planned", user: { login: "bob" }, labels: [{ name: "bug" }], comments: 1, created_at: now }, | |
| 48 | + { number: 2, title: "Develop work", body: "PR body", state: "open", user: { login: "carol" }, labels: [], comments: 0, created_at: now, pull_request: {} }, | |
| 49 | + { number: 3, title: "Merged PR", body: "", state: "closed", user: { login: "carol" }, labels: [], comments: 0, created_at: now, pull_request: {} }, | |
| 50 | + { number: 4, title: "From a fork", body: null, state: "open", user: { login: "mallory" }, labels: [], comments: 0, created_at: now, pull_request: {} }, | |
| 51 | + ], | |
| 52 | + }); | |
| 53 | + srv.on("GET", "/gh/repos/gh-org/widgets/issues/1/comments", { body: [{ body: "me too", user: { login: "erin" }, created_at: "2024-05-02T00:00:00Z" }] }); | |
| 54 | + | |
| 55 | + let n = 0; | |
| 56 | + srv.on("GET", "/api/v3/user", { body: { login: "alice" } }); | |
| 57 | + srv.on("POST", "/api/v3/user/repos", (r) => ({ status: 201, body: { full_name: "alice/widgets", visibility: (r.body as { visibility: string }).visibility } })); | |
| 58 | + srv.on("PATCH", "/api/v3/repos/alice/widgets", { body: {} }); | |
| 59 | + srv.on("PUT", /\/actions\/variables\//, { status: 204 }); | |
| 60 | + srv.on("POST", "/api/v3/repos/alice/widgets/labels", (r) => ({ status: 201, body: r.body })); | |
| 61 | + srv.on("POST", "/api/v3/repos/alice/widgets/issues", (r) => ({ status: 201, body: { number: ++n, title: (r.body as { title: string }).title } })); | |
| 62 | + srv.on("POST", "/api/v3/repos/alice/widgets/pulls", (r) => ({ status: 201, body: { number: ++n, title: (r.body as { title: string }).title } })); | |
| 63 | + srv.on("POST", /\/issues\/\d+\/comments$/, { status: 201, body: {} }); | |
| 64 | + srv.on("PATCH", /\/issues\/\d+$/, { body: {} }); | |
| 65 | + Object.assign(env, { DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_t", GH_TOKEN: "ght", GITHUB_API_URL: `${srv.url}/gh` }); | |
| 66 | + }); | |
| 67 | + afterEach(() => srv.close()); | |
| 68 | + | |
| 69 | + it("creates the repo, pushes branches and tags, and copies issues, PRs, variables and secret names", async () => { | |
| 70 | + const r = await run(["migrate", "github", "gh-org/widgets", "--local", "--issues"], { env }); | |
| 71 | + expect(r.stderr).not.toContain("error"); | |
| 72 | + expect(r.code).toBe(0); | |
| 73 | + | |
| 74 | + // Repository created with GitHub's visibility and description. | |
| 75 | + expect(srv.find("POST", "/api/v3/user/repos")[0]!.body).toEqual({ name: "widgets", description: "Widgets!", visibility: "public" }); | |
| 76 | + | |
| 77 | + // Git data: heads and tags only. | |
| 78 | + const refs = execFileSync("git", ["for-each-ref", "--format=%(refname)"], { cwd: path.join(gitRoot, "alice", "widgets.git"), env }).toString().trim().split("\n").sort(); | |
| 79 | + expect(refs).toEqual(["refs/heads/develop", "refs/heads/main", "refs/tags/v1"]); | |
| 80 | + expect(srv.find("PATCH", "/api/v3/repos/alice/widgets")[0]!.body).toEqual({ default_branch: "develop" }); | |
| 81 | + | |
| 82 | + // Variables copied, secrets listed. | |
| 83 | + expect(srv.find("PUT", /\/actions\/variables\//).map((q) => [q.path, q.body])).toEqual([["/api/v3/repos/alice/widgets/actions/variables/REGION", { value: "eu-west" }]]); | |
| 84 | + expect(r.stdout).toContain("diffinery secret set NPM_TOKEN -R alice/widgets"); | |
| 85 | + | |
| 86 | + // Labels, then issues and PRs in GitHub number order. | |
| 87 | + expect(srv.find("POST", "/api/v3/repos/alice/widgets/labels")[0]!.body).toEqual({ name: "bug", color: "d73a4a", description: "Broken" }); | |
| 88 | + const issues = srv.find("POST", "/api/v3/repos/alice/widgets/issues").map((q) => q.body as { title: string; body: string; labels?: string[] }); | |
| 89 | + expect(issues.map((i) => i.title)).toEqual(["Old bug", "Open issue"]); | |
| 90 | + expect(issues[0]!.body).toBe("> Imported from GitHub issue [#1](https://github.com/gh-org/widgets/issues/1) by @bob on 2024-05-01\n\nit breaks"); | |
| 91 | + expect(issues[0]!.labels).toEqual(["bug"]); | |
| 92 | + const comment = srv.find("POST", /\/issues\/1\/comments$/)[0]!.body as { body: string }; | |
| 93 | + expect(comment.body).toBe("> Imported from GitHub: comment by @erin on 2024-05-02\n\nme too"); | |
| 94 | + expect(srv.find("PATCH", /\/issues\/1$/)[0]!.body).toEqual({ state: "closed", state_reason: "not_planned" }); | |
| 95 | + | |
| 96 | + const pulls = srv.find("POST", "/api/v3/repos/alice/widgets/pulls").map((q) => q.body); | |
| 97 | + expect(pulls).toEqual([{ title: "Develop work", body: "> Imported from GitHub pull request [#2](https://github.com/gh-org/widgets/pull/2) by @carol on 2024-05-01\n\nPR body", head: "develop", base: "main" }]); | |
| 98 | + expect(r.stdout).toContain("#4: head branch is in a fork"); | |
| 99 | + expect(r.stdout).toContain("issues: 2 (1 comments)"); | |
| 100 | + | |
| 101 | + // The GitHub token went to GitHub only, the Diffinery token to Diffinery only. | |
| 102 | + for (const q of srv.requests) { | |
| 103 | + if (q.path.startsWith("/gh/")) expect(q.headers.authorization).toBe("Bearer ght"); | |
| 104 | + else if (q.path.startsWith("/api/")) expect(q.headers.authorization).toBe("Bearer dfy_t"); | |
| 105 | + } | |
| 106 | + }); | |
| 107 | + | |
| 108 | + it("repo clone authenticates for that command only", async () => { | |
| 109 | + await run(["migrate", "github", "gh-org/widgets", "--local"], { env }); | |
| 110 | + const cwd = tempDir("diffinery-clone-"); | |
| 111 | + const r = await run(["repo", "clone", "alice/widgets", "w"], { env, cwd }); | |
| 112 | + expect(r.code).toBe(0); | |
| 113 | + const dir = path.join(cwd, "w"); | |
| 114 | + expect(execFileSync("git", ["branch", "-r"], { cwd: dir, env }).toString()).toContain("origin/develop"); | |
| 115 | + const config = execFileSync("git", ["config", "--list", "--local"], { cwd: dir, env }).toString(); | |
| 116 | + expect(config).toContain(`remote.origin.url=${srv.url}/alice/widgets.git`); | |
| 117 | + expect(config).not.toMatch(/extraheader|dfy_t|Basic/i); | |
| 118 | + // Without the header, the server refuses. | |
| 119 | + const anon = await run(["repo", "clone", "alice/widgets", "w2"], { env: { ...env, DIFFINERY_TOKEN: "", GIT_TERMINAL_PROMPT: "0" }, cwd }); | |
| 120 | + expect(anon.code).not.toBe(0); | |
| 121 | + }); | |
| 122 | + | |
| 123 | + it("without --local, starts a server import and follows it to completion", async () => { | |
| 124 | + const base = { | |
| 125 | + id: "imp1", | |
| 126 | + html_url: `${srv.url}/imports/imp1`, | |
| 127 | + kind: "repository", | |
| 128 | + source: { full_name: "gh-org/widgets" }, | |
| 129 | + target: { full_name: "alice/widgets", html_url: `${srv.url}/alice/widgets` }, | |
| 130 | + git: { phase: "done", message: "pushed", bytes: 123, chunks: 1 }, | |
| 131 | + summary: { secretNames: ["NPM_TOKEN"], environments: { secretNames: ["prod/DEPLOY_KEY", "Staging EU/API_KEY"] } }, | |
| 132 | + warnings: [] as string[], | |
| 133 | + error: null, | |
| 134 | + children: [] as unknown[], | |
| 135 | + }; | |
| 136 | + const steps = (s: string) => [ | |
| 137 | + { name: "repository", title: "Repository", status: "done", message: "alice/widgets", count: null }, | |
| 138 | + { name: "git", title: "Git data", status: s === "succeeded" ? "done" : "running", message: null, count: null }, | |
| 139 | + ]; | |
| 140 | + srv.on("POST", "/api/v3/diffinery/imports", { status: 201, body: { ...base, status: "queued", steps: steps("queued") } }); | |
| 141 | + let polls = 0; | |
| 142 | + srv.on("GET", "/api/v3/diffinery/imports/imp1", () => { | |
| 143 | + polls++; | |
| 144 | + const status = polls >= 2 ? "succeeded" : "running"; | |
| 145 | + return { status: 200, body: { ...base, status, steps: steps(status) } }; | |
| 146 | + }); | |
| 147 | + | |
| 148 | + const r = await run(["migrate", "github", "gh-org/widgets"], { env }); | |
| 149 | + expect(r.code).toBe(0); | |
| 150 | + // The request carried the source, resolved owner, keep-visibility and the GitHub token. | |
| 151 | + const post = srv.find("POST", "/api/v3/diffinery/imports")[0]!.body as Record<string, unknown>; | |
| 152 | + expect(post).toMatchObject({ source: "gh-org/widgets", owner: "alice", visibility: "keep", github_token: "ght" }); | |
| 153 | + // The ambient GitHub token goes along, and the command says so. | |
| 154 | + expect(r.stderr).toContain(`note: sending GH_TOKEN to ${srv.url} so it can read gh-org/widgets from GitHub during the import`); | |
| 155 | + // It polled until the import finished, and reported the result and the secrets to set by hand. | |
| 156 | + expect(polls).toBeGreaterThanOrEqual(2); | |
| 157 | + expect(r.stdout).toContain("Imported gh-org/widgets → alice/widgets"); | |
| 158 | + expect(r.stdout).toContain("diffinery secret set NPM_TOKEN -R alice/widgets"); | |
| 159 | + // An environment secret gets a command that works (`secret set` has no environment option). | |
| 160 | + expect(r.stdout).not.toContain("secret set prod/"); | |
| 161 | + const envLines = r.stdout.split("\n").filter((l) => l.includes("/environments/")); | |
| 162 | + expect(envLines.map((l) => l.trim())).toEqual([ | |
| 163 | + "diffinery api PUT /repos/alice/widgets/environments/prod/secrets/DEPLOY_KEY -f value=...", | |
| 164 | + "diffinery api PUT /repos/alice/widgets/environments/Staging%20EU/secrets/API_KEY -f value=...", | |
| 165 | + ]); | |
| 166 | + // Running the printed command (with a value) sets that environment secret. | |
| 167 | + srv.on("PUT", "/api/v3/repos/alice/widgets/environments/Staging%20EU/secrets/API_KEY", { status: 201 }); | |
| 168 | + const set = await run(envLines[1]!.trim().replace(/^diffinery /, "").replace(/\.\.\.$/, "s3cret").split(" "), { env }); | |
| 169 | + expect(set.code).toBe(0); | |
| 170 | + expect(srv.find("PUT", "/api/v3/repos/alice/widgets/environments/Staging%20EU/secrets/API_KEY")[0]!.body).toEqual({ value: "s3cret" }); | |
| 171 | + // The git data was NOT cloned or pushed by the CLI; the server does that. | |
| 172 | + expect(srv.find("POST", "/api/v3/user/repos")).toHaveLength(0); | |
| 173 | + // The GitHub token never went to Diffinery's own endpoints. | |
| 174 | + for (const q of srv.requests) if (q.path.startsWith("/api/")) expect(q.headers.authorization).toBe("Bearer dfy_t"); | |
| 175 | + }); | |
| 176 | + | |
| 177 | + it("--private and --to override the target", async () => { | |
| 178 | + srv.on("GET", "/api/v3/repos/team/w2", { status: 404, body: { message: "repository not found" } }); | |
| 179 | + srv.on("POST", "/api/v3/orgs/team/repos", { status: 201, body: { full_name: "team/w2", visibility: "private" } }); | |
| 180 | + srv.on("PATCH", "/api/v3/repos/team/w2", { body: {} }); | |
| 181 | + mkdirSync(path.join(gitRoot, "team")); | |
| 182 | + execFileSync("git", ["init", "-q", "--bare", "w2.git"], { cwd: path.join(gitRoot, "team"), env }); | |
| 183 | + const r = await run(["migrate", "github", "https://github.com/gh-org/widgets", "--local", "--to", "team/w2", "--private"], { env }); | |
| 184 | + expect(r.code).toBe(0); | |
| 185 | + expect(srv.find("POST", "/api/v3/orgs/team/repos")[0]!.body).toMatchObject({ name: "w2", visibility: "private" }); | |
| 186 | + expect(srv.find("POST", /\/issues$/)).toHaveLength(0); | |
| 187 | + }); | |
| 188 | +}); |
addedapps/cli/test/misc.test.ts+186−0View file
| @@ -1,0 +1,186 @@ | ||
| 1 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 2 | +import { GitHub, nextLink } from "../src/github.js"; | |
| 3 | +import { withGitConfig } from "../src/git.js"; | |
| 4 | +import { fakeServer, isolatedEnv, run, type FakeServer, type Reply } from "./helpers.js"; | |
| 5 | + | |
| 6 | +describe("run watch", () => { | |
| 7 | + let srv: FakeServer; | |
| 8 | + beforeEach(async () => { | |
| 9 | + srv = await fakeServer(); | |
| 10 | + }); | |
| 11 | + afterEach(() => srv.close()); | |
| 12 | + | |
| 13 | + const runBody = (status: string, conclusion: string | null, jobs: Array<[string, string, string | null]>) => ({ | |
| 14 | + id: "run1", | |
| 15 | + run_number: 4, | |
| 16 | + run_attempt: 1, | |
| 17 | + name: "CI", | |
| 18 | + display_title: "Fix bug", | |
| 19 | + path: ".github/workflows/ci.yml", | |
| 20 | + event: "push", | |
| 21 | + head_branch: "main", | |
| 22 | + head_sha: "abcdef1234", | |
| 23 | + status, | |
| 24 | + conclusion, | |
| 25 | + actor: { login: "alice" }, | |
| 26 | + created_at: new Date().toISOString(), | |
| 27 | + updated_at: new Date().toISOString(), | |
| 28 | + html_url: "http://x/runs/run1", | |
| 29 | + jobs: jobs.map(([id, s, c]) => ({ id, name: `job-${id}`, status: s, conclusion: c, startedAt: null, completedAt: null, steps: [], failure: c === "failure" ? { message: "exit 1" } : null })), | |
| 30 | + }); | |
| 31 | + | |
| 32 | + it("prints job transitions and exits 1 when the run fails", async () => { | |
| 33 | + const states = [ | |
| 34 | + runBody("queued", null, [["a", "queued", null]]), | |
| 35 | + runBody("in_progress", null, [["a", "in_progress", null]]), | |
| 36 | + runBody("in_progress", null, [["a", "in_progress", null]]), | |
| 37 | + runBody("completed", "failure", [["a", "completed", "failure"]]), | |
| 38 | + ]; | |
| 39 | + let i = 0; | |
| 40 | + srv.on("GET", "/api/v3/repos/o/r/actions/runs/run1", () => ({ body: states[Math.min(i++, states.length - 1)] })); | |
| 41 | + const r = await run(["run", "watch", "run1", "-R", "o/r", "--interval", "0"], { env: isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" }) }); | |
| 42 | + expect(r.code).toBe(1); | |
| 43 | + const lines = r.stdout.split("\n").filter((l) => l.includes("job-a")); | |
| 44 | + expect(lines).toHaveLength(3); // queued, in progress, failure (no duplicate for the repeated state) | |
| 45 | + expect(r.stdout).toContain("completed: failure"); | |
| 46 | + }); | |
| 47 | + | |
| 48 | + it("exits 0 on success", async () => { | |
| 49 | + srv.on("GET", "/api/v3/repos/o/r/actions/runs/run1", { body: runBody("completed", "success", [["a", "completed", "success"]]) }); | |
| 50 | + const r = await run(["run", "watch", "run1", "-R", "o/r"], { env: isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" }) }); | |
| 51 | + expect(r.code).toBe(0); | |
| 52 | + }); | |
| 53 | +}); | |
| 54 | + | |
| 55 | +describe("run rerun", () => { | |
| 56 | + let srv: FakeServer; | |
| 57 | + beforeEach(async () => { | |
| 58 | + srv = await fakeServer(); | |
| 59 | + }); | |
| 60 | + afterEach(() => srv.close()); | |
| 61 | + | |
| 62 | + it("re-runs a run, its failed jobs, or one job", async () => { | |
| 63 | + for (const p of ["runs/run1/rerun", "runs/run1/rerun-failed-jobs", "jobs/job1/rerun"]) srv.on("POST", `/api/v3/repos/o/r/actions/${p}`, { status: 201, body: { run_id: "run2" } }); | |
| 64 | + const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" }); | |
| 65 | + for (const [args, p] of [ | |
| 66 | + [["run1"], "runs/run1/rerun"], | |
| 67 | + [["run1", "--failed"], "runs/run1/rerun-failed-jobs"], | |
| 68 | + [["--job", "job1"], "jobs/job1/rerun"], | |
| 69 | + ] as const) { | |
| 70 | + const r = await run(["run", "rerun", ...args, "-R", "o/r"], { env }); | |
| 71 | + expect(r.code).toBe(0); | |
| 72 | + expect(r.stdout).toContain("Re-running as run run2"); | |
| 73 | + expect(srv.find("POST", `/api/v3/repos/o/r/actions/${p}`)).toHaveLength(1); | |
| 74 | + } | |
| 75 | + expect((await run(["run", "rerun", "-R", "o/r"], { env })).code).toBe(2); | |
| 76 | + }); | |
| 77 | +}); | |
| 78 | + | |
| 79 | +describe("GitHub client", () => { | |
| 80 | + let srv: FakeServer; | |
| 81 | + beforeEach(async () => { | |
| 82 | + srv = await fakeServer(); | |
| 83 | + }); | |
| 84 | + afterEach(() => srv.close()); | |
| 85 | + | |
| 86 | + it("follows Link pagination and waits out rate limits", async () => { | |
| 87 | + let hits = 0; | |
| 88 | + srv.on("GET", "/repos/o/r/issues", (req): Reply => { | |
| 89 | + hits++; | |
| 90 | + if (hits === 1) return { status: 403, headers: { "retry-after": "2" }, body: { message: "secondary rate limit" } }; | |
| 91 | + if (req.query.page === "2") return { body: [{ number: 3 }] }; | |
| 92 | + return { body: [{ number: 1 }, { number: 2 }], headers: { link: `<${srv.url}/repos/o/r/issues?page=2&per_page=100>; rel="next", <${srv.url}/repos/o/r/issues?page=2>; rel="last"` } }; | |
| 93 | + }); | |
| 94 | + srv.on("GET", "/repos/o/r/actions/variables", (req) => | |
| 95 | + req.query.page === "2" ? { body: { total_count: 2, variables: [{ name: "B", value: "2" }] } } : { body: { total_count: 2, variables: [{ name: "A", value: "1" }] }, headers: { link: `<${srv.url}/repos/o/r/actions/variables?page=2>; rel="next"` } }, | |
| 96 | + ); | |
| 97 | + srv.on("GET", "/rate", () => (hits++ < 10 ? { status: 403, headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": "1000" }, body: { message: "API rate limit exceeded" } } : { body: { ok: true } })); | |
| 98 | + const waits: number[] = []; | |
| 99 | + const gh = new GitHub({ baseUrl: srv.url, token: "ght", sleep: async (ms) => void waits.push(ms), now: () => 990_000 }); | |
| 100 | + expect((await gh.paginate<{ number: number }>("/repos/o/r/issues", { state: "all" })).map((i) => i.number)).toEqual([1, 2, 3]); | |
| 101 | + expect(waits).toEqual([2000]); | |
| 102 | + expect(srv.requests[0]!.headers.authorization).toBe("Bearer ght"); | |
| 103 | + expect(srv.requests[0]!.query).toMatchObject({ state: "all", per_page: "100" }); | |
| 104 | + expect(await gh.paginate("/repos/o/r/actions/variables", {}, "variables")).toEqual([ | |
| 105 | + { name: "A", value: "1" }, | |
| 106 | + { name: "B", value: "2" }, | |
| 107 | + ]); | |
| 108 | + hits = 5; | |
| 109 | + waits.length = 0; | |
| 110 | + expect(await gh.get("/rate")).toEqual({ ok: true }); | |
| 111 | + expect(waits.length).toBeGreaterThan(0); | |
| 112 | + expect(waits[0]).toBe(11_000); // reset (1000s) - now (990s) + 1s | |
| 113 | + }); | |
| 114 | + | |
| 115 | + it("parses Link headers", () => { | |
| 116 | + expect(nextLink('<https://a/x?page=2>; rel="next", <https://a/x?page=9>; rel="last"')).toBe("https://a/x?page=2"); | |
| 117 | + expect(nextLink('<https://a/x?page=1>; rel="prev"')).toBeNull(); | |
| 118 | + expect(nextLink(null)).toBeNull(); | |
| 119 | + }); | |
| 120 | +}); | |
| 121 | + | |
| 122 | +describe("misc", () => { | |
| 123 | + it("withGitConfig appends to an existing GIT_CONFIG_COUNT", () => { | |
| 124 | + const env = withGitConfig({ GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "a.b", GIT_CONFIG_VALUE_0: "c" }, [["http.extraHeader", "X: y"]]); | |
| 125 | + expect(env).toMatchObject({ GIT_CONFIG_COUNT: "2", GIT_CONFIG_KEY_1: "http.extraHeader", GIT_CONFIG_VALUE_1: "X: y" }); | |
| 126 | + }); | |
| 127 | + | |
| 128 | + it("--help works for every command and --version prints the version", async () => { | |
| 129 | + const { registry } = await import("../src/main.js"); | |
| 130 | + const env = isolatedEnv(); | |
| 131 | + for (const g of registry.groups) { | |
| 132 | + expect((await run([g.name, "--help"], { env })).code).toBe(0); | |
| 133 | + for (const c of g.commands) { | |
| 134 | + const r = await run([...c.name.split(" "), "--help"], { env }); | |
| 135 | + expect(r.code, c.name).toBe(0); | |
| 136 | + expect(r.stdout).toContain(`Usage: diffinery ${c.name}`); | |
| 137 | + } | |
| 138 | + } | |
| 139 | + for (const c of registry.commands) expect((await run([c.name, "--help"], { env })).code).toBe(0); | |
| 140 | + expect((await run(["--version"], { env })).stdout).toMatch(/^diffinery \d+\.\d+\.\d+/); | |
| 141 | + expect((await run(["nope"], { env })).code).toBe(2); | |
| 142 | + }); | |
| 143 | + | |
| 144 | + it("api prints JSON and exits 1 on HTTP errors", async () => { | |
| 145 | + const srv = await fakeServer(); | |
| 146 | + try { | |
| 147 | + srv.on("GET", "/api/v3/user", { body: { login: "alice" } }); | |
| 148 | + srv.on("POST", "/api/v3/repos/o/r/labels", (req) => ({ status: 201, body: req.body })); | |
| 149 | + const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" }); | |
| 150 | + const r = await run(["api", "get", "/user"], { env }); | |
| 151 | + expect(r.code).toBe(0); | |
| 152 | + expect(JSON.parse(r.stdout)).toEqual({ login: "alice" }); | |
| 153 | + const p = await run(["api", "POST", "repos/o/r/labels", "--input", "-"], { env, stdin: '{"name":"bug"}' }); | |
| 154 | + expect(JSON.parse(p.stdout)).toEqual({ name: "bug" }); | |
| 155 | + const missing = await run(["api", "GET", "/nope"], { env }); | |
| 156 | + expect(missing.code).toBe(1); | |
| 157 | + expect(JSON.parse(missing.stdout)).toEqual({ message: "not found" }); | |
| 158 | + } finally { | |
| 159 | + await srv.close(); | |
| 160 | + } | |
| 161 | + }); | |
| 162 | +}); | |
| 163 | + | |
| 164 | +describe("secret set --org", () => { | |
| 165 | + let srv: FakeServer; | |
| 166 | + beforeEach(async () => { | |
| 167 | + srv = await fakeServer(); | |
| 168 | + }); | |
| 169 | + afterEach(() => srv.close()); | |
| 170 | + | |
| 171 | + it("shares an organization secret with the repositories given in --repos", async () => { | |
| 172 | + const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_t" }); | |
| 173 | + srv.on("GET", "/api/v3/repos/acme/web", { body: { id: 11, name: "web" } }); | |
| 174 | + srv.on("GET", "/api/v3/repos/acme/api", { body: { id: 22, name: "api" } }); | |
| 175 | + srv.on("PUT", "/api/v3/orgs/acme/actions/secrets/DEPLOY", { status: 204 }); | |
| 176 | + const r = await run(["secret", "set", "DEPLOY", "--org", "acme", "--body", "v", "--repos", "web, acme/api"], { env }); | |
| 177 | + expect(r.code).toBe(0); | |
| 178 | + expect(srv.find("PUT", "/api/v3/orgs/acme/actions/secrets/DEPLOY")[0]!.body).toEqual({ value: "v", visibility: "selected", selected_repository_ids: [11, 22] }); | |
| 179 | + const plain = await run(["secret", "set", "DEPLOY", "--org", "acme", "--body", "v2"], { env }); | |
| 180 | + expect(plain.code).toBe(0); | |
| 181 | + expect(srv.find("PUT", "/api/v3/orgs/acme/actions/secrets/DEPLOY")[1]!.body).toEqual({ value: "v2" }); | |
| 182 | + const bad = await run(["secret", "set", "DEPLOY", "--org", "acme", "--body", "v", "--visibility", "selected"], { env }); | |
| 183 | + expect(bad.code).toBe(2); | |
| 184 | + expect(bad.stderr).toContain("needs --repos"); | |
| 185 | + }); | |
| 186 | +}); |
addedapps/cli/test/pr.test.ts+307−0View file
| @@ -1,0 +1,307 @@ | ||
| 1 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 2 | +import { parseRemote } from "../src/git.js"; | |
| 3 | +import { fakeServer, gitRepo, isolatedEnv, run, type FakeServer } from "./helpers.js"; | |
| 4 | + | |
| 5 | +const pull = (n: number, extra: Record<string, unknown> = {}) => ({ | |
| 6 | + number: n, | |
| 7 | + title: "Add thing", | |
| 8 | + body: "", | |
| 9 | + state: "open", | |
| 10 | + user: { login: "alice", type: "User" }, | |
| 11 | + labels: [], | |
| 12 | + assignees: [], | |
| 13 | + comments: 0, | |
| 14 | + created_at: new Date().toISOString(), | |
| 15 | + updated_at: new Date().toISOString(), | |
| 16 | + html_url: `http://x/acme/widgets/pull/${n}`, | |
| 17 | + head: { ref: "feature/x", sha: "abc" }, | |
| 18 | + base: { ref: "main" }, | |
| 19 | + ...extra, | |
| 20 | +}); | |
| 21 | + | |
| 22 | +describe("remote parsing", () => { | |
| 23 | + it("recognizes https, ssh and scp-style remotes for the configured host", () => { | |
| 24 | + expect(parseRemote("https://diffinery.com/acme/widgets.git", "diffinery.com")).toEqual({ owner: "acme", name: "widgets" }); | |
| 25 | + expect(parseRemote("http://127.0.0.1:8787/acme/widgets", "127.0.0.1:8787")).toEqual({ owner: "acme", name: "widgets" }); | |
| 26 | + expect(parseRemote("git@diffinery.com:acme/widgets.git", "diffinery.com")).toEqual({ owner: "acme", name: "widgets" }); | |
| 27 | + expect(parseRemote("https://github.com/acme/widgets.git", "diffinery.com")).toBeNull(); | |
| 28 | + }); | |
| 29 | +}); | |
| 30 | + | |
| 31 | +describe("pull requests", () => { | |
| 32 | + let srv: FakeServer; | |
| 33 | + let env: NodeJS.ProcessEnv; | |
| 34 | + beforeEach(async () => { | |
| 35 | + srv = await fakeServer(); | |
| 36 | + env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_t" }); | |
| 37 | + }); | |
| 38 | + afterEach(() => srv.close()); | |
| 39 | + | |
| 40 | + it("pr create infers the repo from origin and the head from the current branch", async () => { | |
| 41 | + srv.on("POST", "/api/v3/repos/acme/widgets/pulls", { status: 201, body: pull(7) }); | |
| 42 | + const cwd = gitRepo(env, `${srv.url}/acme/widgets.git`, "feature/x"); | |
| 43 | + const r = await run(["pr", "create", "--title", "Add thing", "-b", "Body text", "--base", "main", "--draft", "--intent", "Make widgets faster"], { env, cwd }); | |
| 44 | + expect(r.stderr).toBe(""); | |
| 45 | + expect(r.code).toBe(0); | |
| 46 | + expect(r.stdout).toContain("#7"); | |
| 47 | + const req = srv.find("POST", "/api/v3/repos/acme/widgets/pulls")[0]!; | |
| 48 | + expect(req.headers.authorization).toBe("Bearer dfy_t"); | |
| 49 | + expect(req.body).toEqual({ title: "Add thing", body: "Body text", head: "feature/x", base: "main", draft: true, intent: "Make widgets faster" }); | |
| 50 | + }); | |
| 51 | + | |
| 52 | + it("pr create with -R and --head omits unset fields", async () => { | |
| 53 | + srv.on("POST", "/api/v3/repos/o/r/pulls", { status: 201, body: pull(1) }); | |
| 54 | + const r = await run(["pr", "create", "-R", "o/r", "--title", "T", "--head", "topic"], { env }); | |
| 55 | + expect(r.code).toBe(0); | |
| 56 | + expect(srv.find("POST", "/api/v3/repos/o/r/pulls")[0]!.body).toEqual({ title: "T", head: "topic" }); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it("pr create --auto and --no-auto send auto_merge, and say what happened", async () => { | |
| 60 | + srv.on("POST", "/api/v3/repos/o/r/pulls", (req) => | |
| 61 | + (req.body as { auto_merge?: boolean }).auto_merge | |
| 62 | + ? { status: 201, body: { ...pull(2), auto_merge: { merge_method: "squash" } } } | |
| 63 | + : { status: 201, body: { ...pull(3), auto_merge: null, auto_merge_skipped: "main has no required status checks, so auto-merge would not wait for CI" } }, | |
| 64 | + ); | |
| 65 | + const on = await run(["pr", "create", "-R", "o/r", "--title", "T", "--head", "topic", "--auto"], { env }); | |
| 66 | + expect(on.code).toBe(0); | |
| 67 | + expect(srv.find("POST", "/api/v3/repos/o/r/pulls")[0]!.body).toEqual({ title: "T", head: "topic", auto_merge: true }); | |
| 68 | + expect(on.stdout).toContain("auto-merge: on (squash)"); | |
| 69 | + const off = await run(["pr", "create", "-R", "o/r", "--title", "T", "--head", "topic", "--no-auto"], { env }); | |
| 70 | + expect(srv.find("POST", "/api/v3/repos/o/r/pulls")[1]!.body).toEqual({ title: "T", head: "topic", auto_merge: false }); | |
| 71 | + expect(off.stdout).not.toContain("auto-merge: on"); | |
| 72 | + const both = await run(["pr", "create", "-R", "o/r", "--title", "T", "--auto", "--no-auto"], { env }); | |
| 73 | + expect(both.code).toBe(2); | |
| 74 | + }); | |
| 75 | + | |
| 76 | + it("pr create says why auto-merge stayed off when it was asked for", async () => { | |
| 77 | + srv.on("POST", "/api/v3/repos/o/r/pulls", { status: 201, body: { ...pull(4), auto_merge: null, auto_merge_skipped: "main has no required status checks, so auto-merge would not wait for CI" } }); | |
| 78 | + const r = await run(["pr", "create", "-R", "o/r", "--title", "T", "--head", "topic", "--auto"], { env }); | |
| 79 | + expect(r.code).toBe(0); | |
| 80 | + expect(r.stderr).toContain("warning: auto-merge is off: main has no required status checks"); | |
| 81 | + }); | |
| 82 | + | |
| 83 | + it("pr create requires a title", async () => { | |
| 84 | + const r = await run(["pr", "create", "-R", "o/r"], { env }); | |
| 85 | + expect(r.code).toBe(2); | |
| 86 | + expect(r.stderr).toContain("--title is required"); | |
| 87 | + }); | |
| 88 | + | |
| 89 | + it("pr merge sends the method and delete_branch", async () => { | |
| 90 | + srv.on("PUT", "/api/v3/repos/o/r/pulls/12/merge", { body: { merged: true, sha: "0123456789abcdef", method: "squash" } }); | |
| 91 | + const r = await run(["pr", "merge", "12", "-R", "o/r", "--squash", "--delete-branch"], { env }); | |
| 92 | + expect(r.code).toBe(0); | |
| 93 | + expect(r.stdout).toContain("Merged #12"); | |
| 94 | + expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/12/merge")[0]!.body).toEqual({ merge_method: "squash", delete_branch: true }); | |
| 95 | + }); | |
| 96 | + | |
| 97 | + it("pr merge defaults to a merge commit and rejects conflicting flags", async () => { | |
| 98 | + srv.on("PUT", "/api/v3/repos/o/r/pulls/3/merge", { body: { merged: true, sha: "abc", method: "merge" } }); | |
| 99 | + expect((await run(["pr", "merge", "3", "-R", "o/r"], { env })).code).toBe(0); | |
| 100 | + expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/3/merge")[0]!.body).toEqual({ merge_method: "merge" }); | |
| 101 | + const bad = await run(["pr", "merge", "3", "-R", "o/r", "--squash", "--rebase"], { env }); | |
| 102 | + expect(bad.code).toBe(2); | |
| 103 | + }); | |
| 104 | + | |
| 105 | + it("pr merge queues the pull request when the base branch uses a merge queue", async () => { | |
| 106 | + srv.on("PUT", "/api/v3/repos/o/r/pulls/6/merge", { status: 405, body: { message: "main requires a merge queue: add the pull request to the queue" } }); | |
| 107 | + srv.on("PUT", "/api/v3/repos/o/r/pulls/6/merge-queue", { status: 201, body: { state: "queued", entry: { position: 2 }, merge_commit_sha: null, reason: null } }); | |
| 108 | + const r = await run(["pr", "merge", "6", "-R", "o/r", "-d"], { env }); | |
| 109 | + expect(r.code).toBe(0); | |
| 110 | + expect(r.stdout).toContain("Added #6 to the merge queue (position 2)"); | |
| 111 | + expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/6/merge-queue")[0]!.body).toEqual({ delete_branch: true }); | |
| 112 | + // Like gh, --disable-auto turns auto-merge off; a pull request already in the queue leaves it too. | |
| 113 | + srv.on("DELETE", "/api/v3/repos/o/r/pulls/6/auto-merge", { status: 204 }); | |
| 114 | + srv.on("DELETE", "/api/v3/repos/o/r/pulls/6/merge-queue", { status: 204 }); | |
| 115 | + const off = await run(["pr", "merge", "6", "-R", "o/r", "--disable-auto"], { env }); | |
| 116 | + expect(off.code).toBe(0); | |
| 117 | + expect(off.stdout).toContain("Turned off auto-merge for #6 and removed it from the merge queue"); | |
| 118 | + expect(srv.find("DELETE", "/api/v3/repos/o/r/pulls/6/auto-merge")).toHaveLength(1); | |
| 119 | + const admin = await run(["pr", "merge", "6", "-R", "o/r", "--admin"], { env }); | |
| 120 | + expect(admin.code).toBe(1); | |
| 121 | + expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/6/merge").at(-1)!.body).toEqual({ merge_method: "merge", bypass_merge_queue: true, bypass_rules: true }); | |
| 122 | + }); | |
| 123 | + | |
| 124 | + it("pr merge --auto enables auto-merge like gh, and --disable-auto turns it off", async () => { | |
| 125 | + srv.on("PUT", "/api/v3/repos/o/r/pulls/8/auto-merge", { body: { state: "enabled", auto_merge: { merge_method: "squash", enabled_by: { login: "alice" } }, merge_commit_sha: null } }); | |
| 126 | + const r = await run(["pr", "merge", "8", "-R", "o/r", "--auto", "--squash", "-d", "-t", "Ship it", "--match-head-commit", "abc"], { env }); | |
| 127 | + expect(r.stderr).toBe(""); | |
| 128 | + expect(r.code).toBe(0); | |
| 129 | + expect(r.stdout).toContain("Auto-merge is on for #8 (squash)"); | |
| 130 | + expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/8/auto-merge")[0]!.body).toEqual({ merge_method: "squash", delete_branch: true, commit_title: "Ship it", sha: "abc" }); | |
| 131 | + // It never tries a direct merge or the queue itself. | |
| 132 | + expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/8/merge")).toHaveLength(0); | |
| 133 | + expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/8/merge-queue")).toHaveLength(0); | |
| 134 | + // Without a method flag the server picks one (the queue's, or squash under linear history). | |
| 135 | + expect((await run(["pr", "merge", "8", "-R", "o/r", "--auto"], { env })).code).toBe(0); | |
| 136 | + expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/8/auto-merge")[1]!.body).toEqual({}); | |
| 137 | + // When it is ready already, it merges (or joins the queue) at once. | |
| 138 | + srv.on("PUT", "/api/v3/repos/o/r/pulls/9/auto-merge", { body: { state: "merged", auto_merge: null, merge_commit_sha: "0123456789abcdef" } }); | |
| 139 | + expect((await run(["pr", "merge", "9", "-R", "o/r", "--auto"], { env })).stdout).toContain("Merged #9 0123456789ab"); | |
| 140 | + srv.on("PUT", "/api/v3/repos/o/r/pulls/10/auto-merge", { body: { state: "queued", auto_merge: null, merge_commit_sha: null } }); | |
| 141 | + expect((await run(["pr", "merge", "10", "-R", "o/r", "--auto"], { env })).stdout).toContain("Added #10 to the merge queue"); | |
| 142 | + const json = await run(["pr", "merge", "8", "-R", "o/r", "--auto", "--json"], { env }); | |
| 143 | + expect(JSON.parse(json.stdout).state).toBe("enabled"); | |
| 144 | + // --auto can't be combined with --admin (a bypass) or --disable-auto. | |
| 145 | + expect((await run(["pr", "merge", "8", "-R", "o/r", "--auto", "--admin"], { env })).code).toBe(2); | |
| 146 | + expect((await run(["pr", "merge", "8", "-R", "o/r", "--auto", "--disable-auto"], { env })).code).toBe(2); | |
| 147 | + // --disable-auto sends DELETE to the same path; not being in a queue is fine. | |
| 148 | + srv.on("DELETE", "/api/v3/repos/o/r/pulls/8/auto-merge", { status: 204 }); | |
| 149 | + srv.on("DELETE", "/api/v3/repos/o/r/pulls/8/merge-queue", { status: 404, body: { message: "#8 is not in the merge queue" } }); | |
| 150 | + const off = await run(["pr", "merge", "8", "-R", "o/r", "--disable-auto"], { env }); | |
| 151 | + expect(off.stderr).toBe(""); | |
| 152 | + expect(off.code).toBe(0); | |
| 153 | + expect(off.stdout).toContain("Turned off auto-merge for #8"); | |
| 154 | + expect(off.stdout).not.toContain("merge queue"); | |
| 155 | + expect(srv.find("DELETE", "/api/v3/repos/o/r/pulls/8/auto-merge")).toHaveLength(1); | |
| 156 | + expect(JSON.parse((await run(["pr", "merge", "8", "-R", "o/r", "--disable-auto", "--json"], { env })).stdout)).toEqual({ auto_merge: null, dequeued: false }); | |
| 157 | + // Other refusals still surface. | |
| 158 | + srv.on("DELETE", "/api/v3/repos/o/r/pulls/11/auto-merge", { status: 403, body: { message: "write access is required to disable auto-merge" } }); | |
| 159 | + const denied = await run(["pr", "merge", "11", "-R", "o/r", "--disable-auto"], { env }); | |
| 160 | + expect(denied.code).toBe(1); | |
| 161 | + expect(denied.stderr).toContain("write access is required"); | |
| 162 | + }); | |
| 163 | + | |
| 164 | + it("surfaces the server's { message } on errors", async () => { | |
| 165 | + srv.on("PUT", "/api/v3/repos/o/r/pulls/4/merge", { status: 405, body: { message: "pull request is not mergeable: 1 approval required" } }); | |
| 166 | + const r = await run(["pr", "merge", "4", "-R", "o/r"], { env }); | |
| 167 | + expect(r.code).toBe(1); | |
| 168 | + expect(r.stderr).toContain("1 approval required"); | |
| 169 | + }); | |
| 170 | + | |
| 171 | + it("pr review maps flags to review events", async () => { | |
| 172 | + srv.on("POST", "/api/v3/repos/o/r/pulls/5/reviews", { status: 201, body: { id: "rv1", state: "APPROVED" } }); | |
| 173 | + expect((await run(["pr", "review", "5", "-R", "o/r", "--approve"], { env })).code).toBe(0); | |
| 174 | + expect((await run(["pr", "review", "5", "-R", "o/r", "--request-changes", "-b", "fix it"], { env })).code).toBe(0); | |
| 175 | + expect((await run(["pr", "review", "5", "-R", "o/r", "--comment"], { env })).code).toBe(2); | |
| 176 | + expect(srv.find("POST", "/api/v3/repos/o/r/pulls/5/reviews").map((r) => r.body)).toEqual([{ event: "APPROVE" }, { event: "REQUEST_CHANGES", body: "fix it" }]); | |
| 177 | + }); | |
| 178 | + | |
| 179 | + it("pr list and --json", async () => { | |
| 180 | + srv.on("GET", "/api/v3/repos/o/r/pulls", { body: [pull(1), pull(2, { draft: true, title: "WIP" })] }); | |
| 181 | + const r = await run(["pr", "list", "-R", "o/r"], { env }); | |
| 182 | + expect(r.code).toBe(0); | |
| 183 | + expect(r.stdout).toMatch(/#1\s+Add thing/); | |
| 184 | + expect(r.stdout).toMatch(/#2\s+WIP.*draft/); | |
| 185 | + const j = await run(["pr", "list", "-R", "o/r", "--json"], { env }); | |
| 186 | + expect(JSON.parse(j.stdout)).toHaveLength(2); | |
| 187 | + expect(srv.find("GET", "/api/v3/repos/o/r/pulls")[0]!.query.state).toBe("open"); | |
| 188 | + }); | |
| 189 | + | |
| 190 | + it("pr checks exits 1 on failure", async () => { | |
| 191 | + srv.on("GET", "/api/v3/repos/o/r/pulls/9", { body: { ...pull(9), checks: [{ name: "ci / test", state: "failure" }, { name: "lint", state: "success" }] } }); | |
| 192 | + const r = await run(["pr", "checks", "9", "-R", "o/r"], { env }); | |
| 193 | + expect(r.code).toBe(1); | |
| 194 | + expect(r.stdout).toContain("ci / test"); | |
| 195 | + }); | |
| 196 | + | |
| 197 | + it("pr list --head only lists pull requests from that branch", async () => { | |
| 198 | + srv.on("GET", "/api/v3/repos/o/r/pulls", { body: [] }); | |
| 199 | + const r = await run(["pr", "list", "-R", "o/r", "--head", "topic"], { env }); | |
| 200 | + expect(r.code).toBe(0); | |
| 201 | + expect(r.stderr).toContain("No open pull requests from topic in o/r."); | |
| 202 | + expect(srv.find("GET", "/api/v3/repos/o/r/pulls")[0]!.query).toMatchObject({ head: "o:topic", state: "open" }); | |
| 203 | + }); | |
| 204 | + | |
| 205 | + it("pr view, checks, diff, comment, review, ready and merge default to the current branch's pull request", async () => { | |
| 206 | + srv.on("GET", "/api/v3/repos/acme/widgets/pulls", (req) => ({ body: req.query.head === "acme:feature/x" ? [pull(7)] : [] })); | |
| 207 | + srv.on("GET", "/api/v3/repos/acme/widgets/pulls/7", { body: { ...pull(7), checks: [{ name: "ci / test", state: "success" }] } }); | |
| 208 | + srv.on("GET", "/api/v3/repos/acme/widgets/pulls/7/files", { body: [{ filename: "a.txt", status: "modified" }] }); | |
| 209 | + srv.on("POST", "/api/v3/repos/acme/widgets/issues/7/comments", { status: 201, body: { id: "c1", body: "hi", user: { login: "alice" }, created_at: "" } }); | |
| 210 | + srv.on("POST", "/api/v3/repos/acme/widgets/pulls/7/reviews", { status: 201, body: { id: "rv1", state: "APPROVED" } }); | |
| 211 | + srv.on("PATCH", "/api/v3/repos/acme/widgets/pulls/7", { body: pull(7) }); | |
| 212 | + srv.on("PUT", "/api/v3/repos/acme/widgets/pulls/7/merge", { body: { merged: true, sha: "abc", method: "squash" } }); | |
| 213 | + const cwd = gitRepo(env, `${srv.url}/acme/widgets.git`, "feature/x"); | |
| 214 | + const view = await run(["pr", "view"], { env, cwd }); | |
| 215 | + expect(view.stderr).toBe(""); | |
| 216 | + expect(view.code).toBe(0); | |
| 217 | + expect(view.stdout).toContain("#7"); | |
| 218 | + expect(srv.find("GET", "/api/v3/repos/acme/widgets/pulls")[0]!.query).toMatchObject({ head: "acme:feature/x", state: "open" }); | |
| 219 | + expect((await run(["pr", "checks"], { env, cwd })).code).toBe(0); | |
| 220 | + expect((await run(["pr", "diff", "--name-only"], { env, cwd })).stdout).toBe("a.txt\n"); | |
| 221 | + expect((await run(["pr", "comment", "-b", "hi"], { env, cwd })).stdout).toContain("Commented on #7"); | |
| 222 | + expect((await run(["pr", "review", "--approve"], { env, cwd })).stdout).toContain("Approved #7"); | |
| 223 | + expect((await run(["pr", "ready"], { env, cwd })).stdout).toContain("Marked #7 ready"); | |
| 224 | + expect((await run(["pr", "merge", "--squash"], { env, cwd })).stdout).toContain("Merged #7"); | |
| 225 | + // Usage errors are reported before any lookup. | |
| 226 | + const before = srv.requests.length; | |
| 227 | + expect((await run(["pr", "merge", "--auto", "--admin"], { env, cwd })).code).toBe(2); | |
| 228 | + expect((await run(["pr", "comment"], { env, cwd, stdinTTY: true })).code).toBe(2); | |
| 229 | + expect(srv.requests.length).toBe(before); | |
| 230 | + }); | |
| 231 | + | |
| 232 | + it("pr view shows auto-merge and, for an agent, what is left before it lands", async () => { | |
| 233 | + const detail = { mergeable: false, mergeable_state: "blocked", merge_blockers: ["needs 1 more approving review(s)", "required check 'ci' is in_progress"], approvals: 0, required_approvals: 1, checks: [] }; | |
| 234 | + srv.on("GET", "/api/v3/repos/o/r/pulls/3", { body: { ...pull(3), ...detail, auto_merge: { merge_method: "squash" }, auto_merge_blockers: ["required check 'ci' is in_progress"] } }); | |
| 235 | + srv.on("GET", "/api/v3/repos/o/r/pulls/4", { body: { ...pull(4), ...detail, auto_merge: { merge_method: "merge" }, auto_merge_blockers: [] } }); | |
| 236 | + srv.on("GET", "/api/v3/repos/o/r/pulls/5", { body: { ...pull(5), ...detail, auto_merge: { merge_method: "rebase" } } }); | |
| 237 | + srv.on("GET", "/api/v3/repos/o/r/pulls/6", { body: { ...pull(6), ...detail, auto_merge: null } }); | |
| 238 | + const agent = (await run(["pr", "view", "3", "-R", "o/r"], { env })).stdout; | |
| 239 | + expect(agent).toContain("auto-merge: on (squash), it merges by itself once these are done:\n • required check 'ci' is in_progress\n"); | |
| 240 | + expect((await run(["pr", "view", "4", "-R", "o/r"], { env })).stdout).toContain("auto-merge: on (merge), nothing is left, it merges now"); | |
| 241 | + expect((await run(["pr", "view", "5", "-R", "o/r"], { env })).stdout).toContain("auto-merge: on (rebase), it merges by itself once every requirement is met"); | |
| 242 | + expect((await run(["pr", "view", "6", "-R", "o/r"], { env })).stdout).not.toContain("auto-merge"); | |
| 243 | + }); | |
| 244 | + | |
| 245 | + it("without a number, fails clearly when there is no current branch or no open pull request for it", async () => { | |
| 246 | + srv.on("GET", "/api/v3/repos/acme/widgets/pulls", { body: [] }); | |
| 247 | + const cwd = gitRepo(env, `${srv.url}/acme/widgets.git`, "lonely"); | |
| 248 | + const none = await run(["pr", "checks"], { env, cwd }); | |
| 249 | + expect(none.code).toBe(1); | |
| 250 | + expect(none.stderr).toContain("no open pull request for the current branch (lonely) in acme/widgets; pass a pull request number"); | |
| 251 | + const outside = await run(["pr", "view", "-R", "o/r"], { env }); | |
| 252 | + expect(outside.code).toBe(1); | |
| 253 | + expect(outside.stderr).toContain("no current branch"); | |
| 254 | + expect(outside.stderr).toContain("pass a pull request number"); | |
| 255 | + }); | |
| 256 | + | |
| 257 | + it("pr checks --watch polls until nothing is pending", async () => { | |
| 258 | + const states = [["queued", "queued"], ["in_progress", "success"], ["in_progress", "success"], ["failure", "success"]]; | |
| 259 | + let i = 0; | |
| 260 | + srv.on("GET", "/api/v3/repos/o/r/pulls/9", () => { | |
| 261 | + const [a, b] = states[Math.min(i++, states.length - 1)]!; | |
| 262 | + return { body: { ...pull(9), checks: [{ name: "ci / test", state: a }, { name: "lint", state: b }] } }; | |
| 263 | + }); | |
| 264 | + // Piped: one table, the final one. | |
| 265 | + const r = await run(["pr", "checks", "9", "-R", "o/r", "--watch", "-i", "0"], { env }); | |
| 266 | + expect(r.code).toBe(1); | |
| 267 | + expect(srv.find("GET", "/api/v3/repos/o/r/pulls/9")).toHaveLength(4); | |
| 268 | + expect(r.stdout.match(/ci \/ test/g)).toHaveLength(1); | |
| 269 | + expect(r.stdout).toMatch(/ci \/ test\s+failure/); | |
| 270 | + // On a terminal: the table each time it changes. | |
| 271 | + i = 0; | |
| 272 | + const tty = await run(["pr", "checks", "9", "-R", "o/r", "--watch", "--interval", "0"], { env: { ...env, NO_COLOR: "1" }, stdoutTTY: true }); | |
| 273 | + expect(tty.code).toBe(1); | |
| 274 | + expect(tty.stdout.match(/ci \/ test/g)).toHaveLength(3); | |
| 275 | + expect(tty.stdout).toMatch(/ci \/ test\s+queued[\s\S]*ci \/ test\s+in progress[\s\S]*ci \/ test\s+failure/); | |
| 276 | + // --json prints only the final list, and all passing exits 0. | |
| 277 | + states.splice(3, 1, ["success", "skipped"]); | |
| 278 | + i = 0; | |
| 279 | + const j = await run(["pr", "checks", "9", "-R", "o/r", "--watch", "-i", "0", "--json"], { env }); | |
| 280 | + expect(j.code).toBe(0); | |
| 281 | + expect(JSON.parse(j.stdout)).toEqual([{ name: "ci / test", state: "success" }, { name: "lint", state: "skipped" }]); | |
| 282 | + // Without --watch, pending checks exit 8. | |
| 283 | + i = 0; | |
| 284 | + expect((await run(["pr", "checks", "9", "-R", "o/r"], { env })).code).toBe(8); | |
| 285 | + }); | |
| 286 | + | |
| 287 | + it("pr checks --watch waits for the first check right after a push, and gives up after 2 minutes", async () => { | |
| 288 | + let i = 0; | |
| 289 | + srv.on("GET", "/api/v3/repos/o/r/pulls/9", () => ({ body: { ...pull(9), checks: i++ < 2 ? [] : [{ name: "ci / test", state: i < 4 ? "in_progress" : "success" }] } })); | |
| 290 | + const r = await run(["pr", "checks", "9", "-R", "o/r", "--watch", "-i", "0"], { env }); | |
| 291 | + expect(r.code).toBe(0); | |
| 292 | + expect(srv.find("GET", "/api/v3/repos/o/r/pulls/9")).toHaveLength(4); | |
| 293 | + expect(r.stdout).toMatch(/ci \/ test\s+success/); | |
| 294 | + // A repository without CI: no checks ever appear, so it stops after 2 minutes of polls (1 s each at -i 0). | |
| 295 | + srv.on("GET", "/api/v3/repos/o/r/pulls/10", { body: { ...pull(10), checks: [] } }); | |
| 296 | + const none = await run(["pr", "checks", "10", "-R", "o/r", "--watch", "-i", "0"], { env }); | |
| 297 | + expect(none.code).toBe(0); | |
| 298 | + expect(none.stderr).toContain("No checks reported"); | |
| 299 | + expect(srv.find("GET", "/api/v3/repos/o/r/pulls/10")).toHaveLength(121); | |
| 300 | + }); | |
| 301 | + | |
| 302 | + it("pr comment posts to the issue comments endpoint", async () => { | |
| 303 | + srv.on("POST", "/api/v3/repos/o/r/issues/9/comments", { status: 201, body: { id: "c1", body: "hi", user: { login: "alice" }, created_at: "" } }); | |
| 304 | + expect((await run(["pr", "comment", "9", "-R", "o/r", "-b", "hi"], { env })).code).toBe(0); | |
| 305 | + expect(srv.find("POST", "/api/v3/repos/o/r/issues/9/comments")[0]!.body).toEqual({ body: "hi" }); | |
| 306 | + }); | |
| 307 | +}); |
addedapps/cli/test/runner.test.ts+159−0View file
| @@ -1,0 +1,159 @@ | ||
| 1 | +import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 4 | +import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; | |
| 5 | + | |
| 6 | +/** A stand-in for containers/runner/dist/agent.mjs: records argv, env and cwd, then exits. */ | |
| 7 | +function fakeAgent(exitCode = 0): { agent: string; record: string } { | |
| 8 | + const dir = tempDir("diffinery-agent-"); | |
| 9 | + const record = path.join(dir, "record.json"); | |
| 10 | + const agent = path.join(dir, "agent.mjs"); | |
| 11 | + writeFileSync( | |
| 12 | + agent, | |
| 13 | + `import { writeFileSync } from "node:fs"; | |
| 14 | +writeFileSync(${JSON.stringify(record)}, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd(), env: process.env })); | |
| 15 | +console.log("hello from the agent"); | |
| 16 | +console.error("agent stderr line"); | |
| 17 | +process.exit(${exitCode}); | |
| 18 | +`, | |
| 19 | + ); | |
| 20 | + return { agent, record }; | |
| 21 | +} | |
| 22 | + | |
| 23 | +describe("runner", () => { | |
| 24 | + let srv: FakeServer; | |
| 25 | + let env: NodeJS.ProcessEnv; | |
| 26 | + beforeEach(async () => { | |
| 27 | + srv = await fakeServer(); | |
| 28 | + env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_user_token" }); | |
| 29 | + }); | |
| 30 | + afterEach(() => srv.close()); | |
| 31 | + | |
| 32 | + it("--once claims a job, runs the agent with the job's env, and returns its exit code", async () => { | |
| 33 | + let claims = 0; | |
| 34 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", () => | |
| 35 | + ++claims === 1 ? { body: { job_id: "job42", run_id: "run7", name: "build (linux)", token: "djt_repo_job42_secret", api_url: srv.url } } : { status: 204 }, | |
| 36 | + ); | |
| 37 | + const { agent, record } = fakeAgent(3); | |
| 38 | + const work = tempDir("diffinery-work-"); | |
| 39 | + const r = await run(["runner", "-R", "acme/app", "--once", "--agent", agent, "--labels", "gpu, linux", "--name", "box-1", "--work-dir", work], { env }); | |
| 40 | + | |
| 41 | + expect(r.code).toBe(3); | |
| 42 | + const claimReq = srv.find("POST", "/api/v3/repos/acme/app/actions/runners/claim"); | |
| 43 | + expect(claimReq).toHaveLength(1); | |
| 44 | + expect(claimReq[0]!.body).toEqual({ name: "box-1", labels: ["gpu", "linux"] }); | |
| 45 | + expect(claimReq[0]!.headers.authorization).toBe("Bearer dfy_user_token"); | |
| 46 | + | |
| 47 | + const rec = JSON.parse(readFileSync(record, "utf8")) as { argv: string[]; cwd: string; env: Record<string, string> }; | |
| 48 | + expect(rec.argv).toEqual([]); | |
| 49 | + expect(rec.env.DIFFINERY_API_URL).toBe(srv.url); | |
| 50 | + expect(rec.env.DIFFINERY_JOB_ID).toBe("job42"); | |
| 51 | + expect(rec.env.DIFFINERY_JOB_TOKEN).toBe("djt_repo_job42_secret"); | |
| 52 | + expect(rec.env.DIFFINERY_WORK_ROOT).toBe(work); | |
| 53 | + expect(rec.env.DIFFINERY_TOKEN).toBeUndefined(); | |
| 54 | + expect(rec.env.DIFFINERY_URL).toBeUndefined(); | |
| 55 | + | |
| 56 | + expect(r.stdout).toContain("[build (linux)] hello from the agent"); | |
| 57 | + expect(r.stderr).toContain("[build (linux)] agent stderr line"); | |
| 58 | + expect(r.stdout).not.toContain("djt_repo_job42_secret"); | |
| 59 | + }); | |
| 60 | + | |
| 61 | + it("--once with no waiting job exits 0 without running the agent", async () => { | |
| 62 | + srv.on("POST", /\/runners\/claim$/, { status: 204 }); | |
| 63 | + const { agent, record } = fakeAgent(0); | |
| 64 | + const r = await run(["runner", "-R", "acme/app", "-R", "acme/other", "--once", "--agent", agent], { env }); | |
| 65 | + expect(r.code).toBe(0); | |
| 66 | + expect(r.stdout).toContain("No jobs waiting"); | |
| 67 | + expect(existsSync(record)).toBe(false); | |
| 68 | + expect(srv.find("POST", /runners\/claim$/).map((q) => q.path)).toEqual(["/api/v3/repos/acme/app/actions/runners/claim", "/api/v3/repos/acme/other/actions/runners/claim"]); | |
| 69 | + }); | |
| 70 | + | |
| 71 | + it("uses DIFFINERY_RUNNER_AGENT and tries the next repository", async () => { | |
| 72 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 204 }); | |
| 73 | + srv.on("POST", "/api/v3/repos/acme/other/actions/runners/claim", { body: { job_id: "j1", run_id: "r1", name: "test", token: "djt_x", api_url: srv.url } }); | |
| 74 | + const { agent, record } = fakeAgent(0); | |
| 75 | + const r = await run(["runner", "-R", "acme/app", "-R", "acme/other", "--once", "--work-dir", tempDir()], { env: { ...env, DIFFINERY_RUNNER_AGENT: agent } }); | |
| 76 | + expect(r.code).toBe(0); | |
| 77 | + expect(JSON.parse(readFileSync(record, "utf8")).env.DIFFINERY_JOB_ID).toBe("j1"); | |
| 78 | + }); | |
| 79 | + | |
| 80 | + it("explains how to build the agent when none is configured", async () => { | |
| 81 | + const r = await run(["runner", "-R", "acme/app", "--once"], { env }); | |
| 82 | + expect(r.code).toBe(1); | |
| 83 | + expect(r.stderr).toContain("pnpm --filter @diffinery/runner build"); | |
| 84 | + expect(r.stderr).toContain("containers/runner/dist/agent.mjs"); | |
| 85 | + }); | |
| 86 | + | |
| 87 | + it("fails fast on permission errors", async () => { | |
| 88 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 403, body: { message: "admin access required" } }); | |
| 89 | + const { agent } = fakeAgent(0); | |
| 90 | + const r = await run(["runner", "-R", "acme/app", "--agent", agent, "--interval", "0"], { env }); | |
| 91 | + expect(r.code).toBe(1); | |
| 92 | + expect(r.stderr).toContain("admin access required"); | |
| 93 | + }); | |
| 94 | + | |
| 95 | + describe("registered runners", () => { | |
| 96 | + const job = { body: { job_id: "j9", run_id: "r9", name: "build", token: "djt_repo_j9_secret", api_url: "" } }; | |
| 97 | + const registered = { status: 201, body: { id: 7, name: "box", labels: ["linux"], token: "drn_repo_7_runnersecret" } }; | |
| 98 | + | |
| 99 | + it("registers with a registration token, keeps no personal token, and claims with its own", async () => { | |
| 100 | + const bare = isolatedEnv({ DIFFINERY_URL: srv.url }); | |
| 101 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/register", registered); | |
| 102 | + let claims = 0; | |
| 103 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", () => (++claims === 1 ? job : { status: 204 })); | |
| 104 | + const { agent, record } = fakeAgent(0); | |
| 105 | + const r = await run(["runner", "-R", "acme/app", "--registration-token", "drg_abc", "--labels", "linux", "--name", "box", "--once", "--agent", agent, "--work-dir", tempDir()], { env: bare }); | |
| 106 | + expect(r.code).toBe(0); | |
| 107 | + const reg = srv.find("POST", "/api/v3/repos/acme/app/actions/runners/register"); | |
| 108 | + expect(reg[0]!.body).toEqual({ registration_token: "drg_abc", name: "box", labels: ["linux"], ephemeral: false }); | |
| 109 | + expect(reg[0]!.headers.authorization).toBeUndefined(); | |
| 110 | + const claimReq = srv.find("POST", "/api/v3/repos/acme/app/actions/runners/claim"); | |
| 111 | + expect(claimReq[0]!.headers.authorization).toBe("Bearer drn_repo_7_runnersecret"); | |
| 112 | + expect(claimReq[0]!.body).toEqual({}); | |
| 113 | + // The job never sees the runner's token. | |
| 114 | + expect(JSON.stringify(JSON.parse(readFileSync(record, "utf8")).env)).not.toContain("drn_"); | |
| 115 | + expect(r.stdout).toContain("Registered runner"); | |
| 116 | + expect(r.stdout).not.toContain("drn_"); | |
| 117 | + const file = path.join(bare.XDG_CONFIG_HOME ?? path.join(bare.HOME!, ".config"), "diffinery", "runners", srv.host.replace(":", "_"), "acme", "app", "box.json"); | |
| 118 | + expect(JSON.parse(readFileSync(file, "utf8")).token).toBe("drn_repo_7_runnersecret"); | |
| 119 | + expect(statSync(file).mode & 0o777).toBe(0o600); | |
| 120 | + | |
| 121 | + // The next start uses the saved registration. | |
| 122 | + const again = await run(["runner", "-R", "acme/app", "--name", "box", "--once", "--agent", agent], { env: bare }); | |
| 123 | + expect(again.code).toBe(0); | |
| 124 | + expect(srv.find("POST", "/api/v3/repos/acme/app/actions/runners/register")).toHaveLength(1); | |
| 125 | + expect(srv.find("POST", "/api/v3/repos/acme/app/actions/runners/claim").at(-1)!.headers.authorization).toBe("Bearer drn_repo_7_runnersecret"); | |
| 126 | + | |
| 127 | + // A removed runner says so. | |
| 128 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 401, body: { message: "this runner was removed" } }); | |
| 129 | + const gone = await run(["runner", "-R", "acme/app", "--name", "box", "--agent", agent, "--interval", "0"], { env: bare }); | |
| 130 | + expect(gone.code).toBe(1); | |
| 131 | + expect(gone.stderr).toContain("was removed from acme/app"); | |
| 132 | + expect(gone.stderr).toContain(file); | |
| 133 | + }); | |
| 134 | + | |
| 135 | + it("signed in as an admin, registers itself and claims with the runner's token", async () => { | |
| 136 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/registration-token", { status: 201, body: { token: "drg_auto", expires_at: "2099-01-01T00:00:00Z" } }); | |
| 137 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/register", registered); | |
| 138 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 204 }); | |
| 139 | + const { agent } = fakeAgent(0); | |
| 140 | + const r = await run(["runner", "-R", "acme/app", "--name", "box", "--once", "--agent", agent], { env }); | |
| 141 | + expect(r.code).toBe(0); | |
| 142 | + expect(srv.find("POST", /registration-token$/)[0]!.headers.authorization).toBe("Bearer dfy_user_token"); | |
| 143 | + expect(srv.find("POST", /register$/)[0]!.body).toMatchObject({ registration_token: "drg_auto" }); | |
| 144 | + expect(srv.find("POST", /claim$/)[0]!.headers.authorization).toBe("Bearer drn_repo_7_runnersecret"); | |
| 145 | + }); | |
| 146 | + | |
| 147 | + it("--ephemeral takes one job, saves nothing and exits with its code", async () => { | |
| 148 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/register", registered); | |
| 149 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", job); | |
| 150 | + const { agent } = fakeAgent(4); | |
| 151 | + const bare = isolatedEnv({ DIFFINERY_URL: srv.url }); | |
| 152 | + const r = await run(["runner", "-R", "acme/app", "--registration-token", "drg_abc", "--ephemeral", "--name", "box", "--agent", agent, "--work-dir", tempDir()], { env: bare }); | |
| 153 | + expect(r.code).toBe(4); | |
| 154 | + expect(srv.find("POST", /register$/)[0]!.body).toMatchObject({ ephemeral: true }); | |
| 155 | + expect(srv.find("POST", /claim$/)).toHaveLength(1); | |
| 156 | + expect(r.stdout).not.toContain("Registered runner"); | |
| 157 | + }); | |
| 158 | + }); | |
| 159 | +}); |
addedapps/cli/test/setup.test.ts+412−0View file
| @@ -1,0 +1,412 @@ | ||
| 1 | +import { createHash } from "node:crypto"; | |
| 2 | +import { execFileSync } from "node:child_process"; | |
| 3 | +import { chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; | |
| 4 | +import path from "node:path"; | |
| 5 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 6 | +import { withUrlOption } from "../src/commands/claude-plugins.js"; | |
| 7 | +import { configPath, writeConfig } from "../src/config.js"; | |
| 8 | +import { fakeServer, gitRepo, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; | |
| 9 | + | |
| 10 | +const helpers = (env: NodeJS.ProcessEnv, url: string) => execFileSync("git", ["config", "--global", "--get-all", `credential.${url}.helper`], { env }).toString().split("\n").slice(0, -1); | |
| 11 | + | |
| 12 | +/** A gh that records how it was called; `fail` makes `auth login` fail. */ | |
| 13 | +function fakeGh(fail = false): { dir: string; seen: string } { | |
| 14 | + const dir = tempDir("diffinery-fake-gh-"); | |
| 15 | + const seen = path.join(dir, "seen"); | |
| 16 | + writeFileSync(path.join(dir, "gh"), `#!/bin/sh\n[ "$1" = --version ] && exit 0\nprintf '%s ' "$@" > "${seen}.args"\ncat > "${seen}.stdin"\n${fail ? "echo 'gh: nope' >&2; exit 1\n" : ""}`); | |
| 17 | + chmodSync(path.join(dir, "gh"), 0o755); | |
| 18 | + return { dir, seen }; | |
| 19 | +} | |
| 20 | + | |
| 21 | +describe("diffinery setup", () => { | |
| 22 | + let srv: FakeServer; | |
| 23 | + beforeEach(async () => { | |
| 24 | + srv = await fakeServer(); | |
| 25 | + srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_good" ? { body: { login: "alice", type: "User" } } : { status: 401, body: { message: "bad credentials" } })); | |
| 26 | + srv.on("POST", "/-/cli/token", { body: { access_token: "dfy_good", token_type: "bearer", scope: "write", expires_at: "2027-01-12T00:00:00Z", login: "alice" } }); | |
| 27 | + }); | |
| 28 | + afterEach(() => { | |
| 29 | + vi.unstubAllGlobals(); | |
| 30 | + return srv.close(); | |
| 31 | + }); | |
| 32 | + | |
| 33 | + /** A browser where the person clicks Authorize. */ | |
| 34 | + const approve = async (url: string) => { | |
| 35 | + const u = new URL(url); | |
| 36 | + await fetch(`http://127.0.0.1:${u.searchParams.get("port")}/callback?code=c&state=${u.searchParams.get("state")}`, { redirect: "manual" }); | |
| 37 | + return true; | |
| 38 | + }; | |
| 39 | + | |
| 40 | + it("signs in through the browser, then sets up git, even when an agent runs it without a terminal", async () => { | |
| 41 | + const env = isolatedEnv(); | |
| 42 | + let opened = 0; | |
| 43 | + const r = await run(["setup", "--url", srv.url], { env, stdinTTY: false, openUrl: (u) => (opened++, approve(u)) }); | |
| 44 | + expect(r.stderr).not.toContain("error"); | |
| 45 | + expect(r.code).toBe(0); | |
| 46 | + expect(opened).toBe(1); | |
| 47 | + expect(r.stdout).toContain(`Logged in to ${srv.url} as alice`); | |
| 48 | + expect(r.stdout).toContain(`git will use diffinery for ${srv.url}`); | |
| 49 | + expect(r.stdout).toContain(`${srv.url}/docs/connect`); | |
| 50 | + expect(r.stderr).toContain("Skipped gh"); | |
| 51 | + expect(r.stdout + r.stderr).not.toContain("dfy_good"); | |
| 52 | + expect(JSON.parse(readFileSync(configPath(env), "utf8"))).toEqual({ url: srv.url, token: "dfy_good" }); | |
| 53 | + expect(helpers(env, srv.url)).toEqual(["", "!diffinery auth git-credential"]); | |
| 54 | + }); | |
| 55 | + | |
| 56 | + it("offers auto-merge for the person's pull requests on a terminal, and turns it on with yes", async () => { | |
| 57 | + const env = isolatedEnv(); | |
| 58 | + await writeConfig(env, { url: srv.url, token: "dfy_good" }); | |
| 59 | + let enabled = false; | |
| 60 | + srv.on("GET", "/api/v3/diffinery/auto-merge", () => ({ body: { enabled, merge_method: enabled ? "default" : null } })); | |
| 61 | + srv.on("PUT", "/api/v3/diffinery/auto-merge", () => ((enabled = true), { body: { enabled: true, merge_method: "default" } })); | |
| 62 | + // No terminal: nothing asked, nothing changed. | |
| 63 | + const quiet = await run(["setup"], { env, stdinTTY: false }); | |
| 64 | + expect(quiet.code).toBe(0); | |
| 65 | + expect(quiet.stderr).not.toContain("Turn on auto-merge"); | |
| 66 | + expect(srv.find("PUT", "/api/v3/diffinery/auto-merge")).toHaveLength(0); | |
| 67 | + // Asked and declined. | |
| 68 | + const no = await run(["setup"], { env, stdinTTY: true, stdin: "\n" }); | |
| 69 | + expect(no.code).toBe(0); | |
| 70 | + expect(no.stderr).toContain("Turn on auto-merge for pull requests you open? [y/N]"); | |
| 71 | + expect(no.stderr).toContain("Auto-merge stays off"); | |
| 72 | + expect(srv.find("PUT", "/api/v3/diffinery/auto-merge")).toHaveLength(0); | |
| 73 | + // Asked and accepted. | |
| 74 | + const yes = await run(["setup"], { env, stdinTTY: true, stdin: "y\n" }); | |
| 75 | + expect(yes.code).toBe(0); | |
| 76 | + expect(srv.find("PUT", "/api/v3/diffinery/auto-merge")[0]!.body).toEqual({ enabled: true }); | |
| 77 | + expect(yes.stdout).toContain(`Auto-merge turns on by itself for pull requests you open (change it in ${srv.url}/settings/pull-requests)`); | |
| 78 | + // Once on, it only says so. | |
| 79 | + const again = await run(["setup"], { env, stdinTTY: true, stdin: "" }); | |
| 80 | + expect(again.stderr).not.toContain("[y/N]"); | |
| 81 | + expect(again.stdout).toContain("Auto-merge turns on by itself for pull requests you open"); | |
| 82 | + expect(srv.find("PUT", "/api/v3/diffinery/auto-merge")).toHaveLength(1); | |
| 83 | + }); | |
| 84 | + | |
| 85 | + it("turns auto-merge on without asking with --auto-merge, and never asks with --no-auto-merge or for an agent", async () => { | |
| 86 | + const env = isolatedEnv(); | |
| 87 | + await writeConfig(env, { url: srv.url, token: "dfy_good" }); | |
| 88 | + srv.on("GET", "/api/v3/diffinery/auto-merge", { body: { enabled: false, merge_method: null } }); | |
| 89 | + srv.on("PUT", "/api/v3/diffinery/auto-merge", { body: { enabled: true, merge_method: "default" } }); | |
| 90 | + const skip = await run(["setup", "--no-auto-merge"], { env, stdinTTY: true, stdin: "y\n" }); | |
| 91 | + expect(skip.stderr).not.toContain("[y/N]"); | |
| 92 | + expect(srv.find("PUT", "/api/v3/diffinery/auto-merge")).toHaveLength(0); | |
| 93 | + const yes = await run(["setup", "--auto-merge"], { env, stdinTTY: false }); | |
| 94 | + expect(yes.code).toBe(0); | |
| 95 | + expect(srv.find("PUT", "/api/v3/diffinery/auto-merge")).toHaveLength(1); | |
| 96 | + srv.on("GET", "/api/v3/user", { body: { login: "alice-bot", type: "Bot" } }); | |
| 97 | + const bot = await run(["setup", "--auto-merge"], { env, stdinTTY: true, stdin: "y\n" }); | |
| 98 | + expect(bot.stderr).not.toContain("[y/N]"); | |
| 99 | + expect(srv.find("PUT", "/api/v3/diffinery/auto-merge")).toHaveLength(1); | |
| 100 | + }); | |
| 101 | + | |
| 102 | + it("keeps a sign-in that still works, and is safe to run again", async () => { | |
| 103 | + const env = isolatedEnv(); | |
| 104 | + await writeConfig(env, { url: srv.url, token: "dfy_good" }); | |
| 105 | + for (let i = 0; i < 2; i++) { | |
| 106 | + const r = await run(["setup"], { env, openUrl: async () => expect.fail("opened a browser") }); | |
| 107 | + expect(r.code).toBe(0); | |
| 108 | + expect(r.stdout).toContain(`Signed in to ${srv.url} as alice`); | |
| 109 | + } | |
| 110 | + expect(srv.find("POST", "/-/cli/token")).toHaveLength(0); | |
| 111 | + expect(helpers(env, srv.url)).toEqual(["", "!diffinery auth git-credential"]); | |
| 112 | + }); | |
| 113 | + | |
| 114 | + it("signs in again when the saved token no longer works", async () => { | |
| 115 | + const env = isolatedEnv(); | |
| 116 | + await writeConfig(env, { url: srv.url, token: "dfy_expired" }); | |
| 117 | + const r = await run(["setup"], { env, openUrl: approve }); | |
| 118 | + expect(r.code).toBe(0); | |
| 119 | + expect(r.stdout).toContain("Logged in to"); | |
| 120 | + expect(JSON.parse(readFileSync(configPath(env), "utf8")).token).toBe("dfy_good"); | |
| 121 | + }); | |
| 122 | + | |
| 123 | + describe("with gh", () => { | |
| 124 | + const host = "https://forge.example.com"; | |
| 125 | + // Requests to the HTTPS host reach the fake server, since gh only signs in to HTTPS hosts. | |
| 126 | + beforeEach(() => { | |
| 127 | + const real = fetch; | |
| 128 | + vi.stubGlobal("fetch", (input: string | URL | Request, init?: RequestInit) => real(String(input instanceof Request ? input.url : input).replace(host, srv.url), init)); | |
| 129 | + }); | |
| 130 | + | |
| 131 | + it("signs gh in with the token on stdin", async () => { | |
| 132 | + const gh = fakeGh(); | |
| 133 | + const env = isolatedEnv({ PATH: `${gh.dir}${path.delimiter}${process.env.PATH}` }); | |
| 134 | + await writeConfig(env, { url: host, token: "dfy_good" }); | |
| 135 | + const r = await run(["setup"], { env }); | |
| 136 | + expect(r.code).toBe(0); | |
| 137 | + expect(r.stdout).toContain("gh is signed in to forge.example.com"); | |
| 138 | + expect(readFileSync(`${gh.seen}.args`, "utf8").trim()).toBe("auth login --hostname forge.example.com --with-token --git-protocol https"); | |
| 139 | + expect(readFileSync(`${gh.seen}.stdin`, "utf8")).toBe("dfy_good\n"); | |
| 140 | + }); | |
| 141 | + | |
| 142 | + it("leaves gh to GH_ENTERPRISE_TOKEN when it's in the environment, since gh won't sign in over it", async () => { | |
| 143 | + const gh = fakeGh(); | |
| 144 | + const env = isolatedEnv({ PATH: `${gh.dir}${path.delimiter}${process.env.PATH}`, GH_ENTERPRISE_TOKEN: "dfy_good", GH_HOST: "forge.example.com" }); | |
| 145 | + await writeConfig(env, { url: host, token: "dfy_good" }); | |
| 146 | + const r = await run(["setup"], { env }); | |
| 147 | + expect(r.code).toBe(0); | |
| 148 | + expect(r.stdout).toContain("gh uses GH_ENTERPRISE_TOKEN for forge.example.com"); | |
| 149 | + expect(() => readFileSync(`${gh.seen}.args`)).toThrow(); | |
| 150 | + const other = await run(["setup"], { env: { ...env, GH_ENTERPRISE_TOKEN: "dfy_other" } }); | |
| 151 | + expect(other.code).toBe(1); | |
| 152 | + expect(other.stderr).toContain("isn't your Diffinery token"); | |
| 153 | + expect(other.stdout + other.stderr).not.toMatch(/dfy_(good|other)/); | |
| 154 | + }); | |
| 155 | + | |
| 156 | + it("says when gh isn't installed", async () => { | |
| 157 | + // A PATH with git and nothing else, wherever gh lives on this machine. | |
| 158 | + const dir = tempDir("diffinery-no-gh-"); | |
| 159 | + symlinkSync(execFileSync("sh", ["-c", "command -v git"]).toString().trim(), path.join(dir, "git")); | |
| 160 | + const env = isolatedEnv({ PATH: dir }); | |
| 161 | + await writeConfig(env, { url: host, token: "dfy_good" }); | |
| 162 | + const r = await run(["setup"], { env }); | |
| 163 | + expect(r.code).toBe(0); | |
| 164 | + expect(r.stderr).toContain("Skipped gh, which isn't installed"); | |
| 165 | + }); | |
| 166 | + | |
| 167 | + it("finishes the rest and exits 1 when gh fails", async () => { | |
| 168 | + const gh = fakeGh(true); | |
| 169 | + const env = isolatedEnv({ PATH: `${gh.dir}${path.delimiter}${process.env.PATH}` }); | |
| 170 | + await writeConfig(env, { url: host, token: "dfy_good" }); | |
| 171 | + const r = await run(["setup"], { env }); | |
| 172 | + expect(r.code).toBe(1); | |
| 173 | + expect(r.stderr).toContain("gh auth login failed: gh: nope"); | |
| 174 | + expect(r.stdout).toContain("git will use diffinery"); | |
| 175 | + expect(r.stdout).toContain(`${host}/docs/connect`); | |
| 176 | + }); | |
| 177 | + }); | |
| 178 | +}); | |
| 179 | + | |
| 180 | +describe("diffinery setup --repo (an agent environment, as the hosted agent-setup.sh runs it)", () => { | |
| 181 | + let srv: FakeServer; | |
| 182 | + let push: boolean; | |
| 183 | + beforeEach(async () => { | |
| 184 | + const root = tempDir("diffinery-git-root-"); | |
| 185 | + execFileSync("git", ["init", "-q", "--bare", "-b", "main", path.join(root, "ada", "zutrix.git")]); | |
| 186 | + const seed = gitRepo(isolatedEnv(), path.join(root, "ada", "zutrix.git")); | |
| 187 | + execFileSync("git", ["push", "-q", "origin", "main"], { cwd: seed }); | |
| 188 | + srv = await fakeServer({ gitRoot: root }); | |
| 189 | + push = true; | |
| 190 | + srv.on("GET", "/healthz", { body: { ok: true } }); | |
| 191 | + srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_agent" ? { body: { login: "zutrix-agent", type: "Bot" } } : { status: 401, body: { message: "bad credentials" } })); | |
| 192 | + srv.on("GET", "/api/v3/repos/ada/zutrix", () => ({ body: { full_name: "ada/zutrix", permissions: { pull: true, push } } })); | |
| 193 | + srv.on("POST", "/api/v3/repos/ada/zutrix/diffinery/agent-setup/checks", { status: 201, body: { ok: true } }); | |
| 194 | + }); | |
| 195 | + afterEach(() => srv.close()); | |
| 196 | + | |
| 197 | + /** A PATH with git and a diffinery command, and no gh. */ | |
| 198 | + const machine = (extra: NodeJS.ProcessEnv = {}) => { | |
| 199 | + const bin = tempDir("diffinery-agent-bin-"); | |
| 200 | + symlinkSync(execFileSync("sh", ["-c", "command -v git"]).toString().trim(), path.join(bin, "git")); | |
| 201 | + writeFileSync(path.join(bin, "diffinery"), "#!/bin/sh\nexit 0\n"); | |
| 202 | + chmodSync(path.join(bin, "diffinery"), 0o755); | |
| 203 | + return isolatedEnv({ PATH: bin, DIFFINERY_CLONE_DIR: tempDir("diffinery-clones-"), ...extra }); | |
| 204 | + }; | |
| 205 | + const reports = () => srv.find("POST", "/api/v3/repos/ada/zutrix/diffinery/agent-setup/checks").map((r) => r.body as { checks: Array<{ name: string; ok: boolean; message: string }> }); | |
| 206 | + | |
| 207 | + it("checks each part with the token from the environment, clones, reports, and writes no token to disk", async () => { | |
| 208 | + const env = machine({ DIFFINERY_TOKEN: "dfy_agent" }); | |
| 209 | + const r = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url], { env }); | |
| 210 | + expect(r.stderr).toBe(""); | |
| 211 | + expect(r.code).toBe(0); | |
| 212 | + for (const s of [`✓ server: ${srv.host} is reachable`, "✓ DIFFINERY_TOKEN: acts as zutrix-agent (agent)", "✓ repository: ada/zutrix with write access", "✓ git: git can reach", `✓ clone: cloned into ${path.join(env.DIFFINERY_CLONE_DIR!, "zutrix")}`, "✓ gh: skipped", "Ready:"]) expect(r.stdout).toContain(s); | |
| 213 | + expect(r.stdout).not.toContain("dfy_agent"); | |
| 214 | + expect(JSON.parse(readFileSync(configPath(env), "utf8"))).toEqual({ url: srv.url }); | |
| 215 | + const [report] = reports(); | |
| 216 | + expect(report!.checks.every((l) => l.ok)).toBe(true); | |
| 217 | + // Again, as at each session start: the clone is fetched, not cloned again. | |
| 218 | + const again = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url], { env }); | |
| 219 | + expect(again.code).toBe(0); | |
| 220 | + expect(again.stdout).toContain("(up to date)"); | |
| 221 | + }); | |
| 222 | + | |
| 223 | + it("says what to change, exits 1, and still reports, when a check fails", async () => { | |
| 224 | + push = false; | |
| 225 | + const env = machine({ DIFFINERY_TOKEN: "dfy_agent" }); | |
| 226 | + const r = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url, "--no-clone"], { env }); | |
| 227 | + expect(r.code).toBe(1); | |
| 228 | + expect(r.stdout).toContain(`✗ repository: zutrix-agent can read ada/zutrix but not push: use a token from ${srv.url}/ada/zutrix/settings/agent-setup`); | |
| 229 | + expect(r.stdout).toContain("1 of"); | |
| 230 | + expect(reports()[0]!.checks.find((l) => l.name === "repository")!.ok).toBe(false); | |
| 231 | + }); | |
| 232 | + | |
| 233 | + it("names the token that's missing or refused, with where to get a new one", async () => { | |
| 234 | + const missing = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url, "--no-clone"], { env: machine() }); | |
| 235 | + expect(missing.code).toBe(1); | |
| 236 | + expect(missing.stdout).toContain(`✗ DIFFINERY_TOKEN: not set: add DIFFINERY_TOKEN=<token> to the environment variables (generate one on ${srv.url}/ada/zutrix/settings/agent-setup)`); | |
| 237 | + const refused = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url, "--no-clone"], { env: machine({ DIFFINERY_TOKEN: "dfy_revoked" }) }); | |
| 238 | + expect(refused.code).toBe(1); | |
| 239 | + expect(refused.stdout).toContain("✗ DIFFINERY_TOKEN: refused (revoked or expired)"); | |
| 240 | + expect(refused.stdout).not.toContain("dfy_revoked"); | |
| 241 | + expect(reports()).toHaveLength(0); | |
| 242 | + }); | |
| 243 | + | |
| 244 | + it("refuses a --repo that isn't OWNER/NAME", async () => { | |
| 245 | + const r = await run(["setup", "--repo", "zutrix", "--url", srv.url], { env: machine({ DIFFINERY_TOKEN: "dfy_agent" }) }); | |
| 246 | + expect(r.code).toBe(2); | |
| 247 | + expect(r.stderr).toContain("--repo expects OWNER/NAME"); | |
| 248 | + }); | |
| 249 | +}); | |
| 250 | + | |
| 251 | +describe("Claude Code plugins in diffinery setup", () => { | |
| 252 | + let srv: FakeServer; | |
| 253 | + const zips = { diffinery: "PK\u0003\u0004 diffinery plugin", "diffinery-ci": "PK\u0003\u0004 diffinery-ci plugin" } as const; | |
| 254 | + const sha = (s: string) => createHash("sha256").update(s).digest("hex"); | |
| 255 | + beforeEach(async () => { | |
| 256 | + srv = await fakeServer(); | |
| 257 | + srv.on("GET", "/api/v3/user", { body: { login: "alice", type: "User" } }); | |
| 258 | + for (const [name, body] of Object.entries(zips)) | |
| 259 | + srv.on("GET", `/-/claude/${name}.zip`, { body, headers: { "content-type": "application/zip", "x-diffinery-plugin-version": name === "diffinery" ? "0.1.1" : "0.2.0", "x-diffinery-plugin-sha256": sha(body) } }); | |
| 260 | + }); | |
| 261 | + afterEach(() => srv.close()); | |
| 262 | + | |
| 263 | + /** A machine with Claude Code: its settings directory, with `settings` in it when given. */ | |
| 264 | + const withClaude = async (settings?: unknown) => { | |
| 265 | + const env = isolatedEnv({ DIFFINERY_NO_CLAUDE_PLUGINS: "" }); | |
| 266 | + await writeConfig(env, { url: srv.url, token: "dfy_good" }); | |
| 267 | + mkdirSync(path.join(env.HOME!, ".claude")); | |
| 268 | + if (settings !== undefined) writeFileSync(path.join(env.HOME!, ".claude", "settings.json"), typeof settings === "string" ? settings : JSON.stringify(settings)); | |
| 269 | + return env; | |
| 270 | + }; | |
| 271 | + const settingsOf = (env: NodeJS.ProcessEnv) => JSON.parse(readFileSync(path.join(env.HOME!, ".claude", "settings.json"), "utf8")); | |
| 272 | + const dirsOf = (env: NodeJS.ProcessEnv) => (settingsOf(env).env.CLAUDE_CODE_PLUGIN_DIRS as string).split(path.delimiter); | |
| 273 | + | |
| 274 | + it("saves both plugins and names them in Claude Code's settings, keeping what was there, and runs again cleanly", async () => { | |
| 275 | + const env = await withClaude({ model: "opus", env: { FOO: "1", CLAUDE_CODE_PLUGIN_DIRS: "/opt/mine" } }); | |
| 276 | + for (let i = 0; i < 2; i++) { | |
| 277 | + const r = await run(["setup"], { env, openUrl: async () => expect.fail("opened a browser") }); | |
| 278 | + expect(r.code).toBe(0); | |
| 279 | + expect(r.stdout).toContain("✓ Claude Code plugins: diffinery 0.1.1 and diffinery-ci 0.2.0 load in new Claude Code sessions"); | |
| 280 | + } | |
| 281 | + const dir = path.join(env.HOME!, ".claude", "diffinery-plugins"); | |
| 282 | + expect(dirsOf(env)).toEqual(["/opt/mine", path.join(dir, "diffinery.zip"), path.join(dir, "diffinery-ci.zip")]); | |
| 283 | + expect(settingsOf(env)).toMatchObject({ model: "opus", env: { FOO: "1" } }); | |
| 284 | + expect(readFileSync(path.join(dir, "diffinery-ci.zip"), "utf8")).toBe(zips["diffinery-ci"]); | |
| 285 | + }); | |
| 286 | + | |
| 287 | + it("does nothing without Claude Code, with --no-claude-plugins, or for a plugin installed from the marketplace", async () => { | |
| 288 | + // No Claude Code: no ~/.claude, and a PATH with git alone. | |
| 289 | + const bin = tempDir("diffinery-no-claude-"); | |
| 290 | + symlinkSync(execFileSync("sh", ["-c", "command -v git"]).toString().trim(), path.join(bin, "git")); | |
| 291 | + const none = isolatedEnv({ DIFFINERY_NO_CLAUDE_PLUGINS: "", PATH: bin }); | |
| 292 | + await writeConfig(none, { url: srv.url, token: "dfy_good" }); | |
| 293 | + expect((await run(["setup"], { env: none })).stdout).not.toContain("Claude Code plugins"); | |
| 294 | + expect(existsSync(path.join(none.HOME!, ".claude"))).toBe(false); | |
| 295 | + | |
| 296 | + const skip = await withClaude(); | |
| 297 | + expect((await run(["setup", "--no-claude-plugins"], { env: skip })).stdout).not.toContain("Claude Code plugins"); | |
| 298 | + expect(existsSync(path.join(skip.HOME!, ".claude", "settings.json"))).toBe(false); | |
| 299 | + | |
| 300 | + const market = await withClaude({ enabledPlugins: { "diffinery-ci@diffinery": true } }); | |
| 301 | + const r = await run(["setup"], { env: market }); | |
| 302 | + expect(r.stdout).toContain("diffinery 0.1.1 load in new Claude Code sessions"); | |
| 303 | + expect(r.stdout).toContain("diffinery-ci already installed from the Diffinery marketplace"); | |
| 304 | + expect(dirsOf(market)).toEqual([path.join(market.HOME!, ".claude", "diffinery-plugins", "diffinery.zip")]); | |
| 305 | + }); | |
| 306 | + | |
| 307 | + it("points the plugins' Diffinery URL setting at the server, for its own copies and marketplace ones", async () => { | |
| 308 | + const env = await withClaude({ enabledPlugins: { "diffinery-ci@diffinery": true }, pluginConfigs: { "diffinery-ci@diffinery": { options: { notes: "all", url: "https://old.example" } }, "other@x": { options: { a: 1 } } } }); | |
| 309 | + const r = await run(["setup"], { env }); | |
| 310 | + expect(r.code).toBe(0); | |
| 311 | + expect(r.stdout).toContain(`their Diffinery URL is ${srv.url}`); | |
| 312 | + expect(settingsOf(env).pluginConfigs).toEqual({ | |
| 313 | + "diffinery@inline": { options: { url: srv.url } }, | |
| 314 | + "diffinery-ci@diffinery": { options: { notes: "all", url: srv.url } }, | |
| 315 | + "other@x": { options: { a: 1 } }, | |
| 316 | + }); | |
| 317 | + // diffinery.com is the setting's default: nothing to write unless a URL was saved before. | |
| 318 | + expect(withUrlOption(undefined, ["diffinery@inline"], "https://diffinery.com")).toBe(null); | |
| 319 | + expect(withUrlOption({ "diffinery@inline": { options: { url: srv.url } } }, ["diffinery@inline"], "https://diffinery.com")).toEqual({ "diffinery@inline": { options: { url: "https://diffinery.com" } } }); | |
| 320 | + expect(withUrlOption({ "diffinery@inline": { options: { url: srv.url } } }, ["diffinery@inline"], srv.url)).toBe(null); | |
| 321 | + }); | |
| 322 | + | |
| 323 | + it("leaves settings it can't read alone, and refuses a download that doesn't match its checksum", async () => { | |
| 324 | + const broken = await withClaude("{ not json"); | |
| 325 | + const r = await run(["setup"], { env: broken }); | |
| 326 | + expect(r.code).toBe(1); | |
| 327 | + expect(r.stderr).toContain("isn't valid JSON, so it was left alone"); | |
| 328 | + expect(readFileSync(path.join(broken.HOME!, ".claude", "settings.json"), "utf8")).toBe("{ not json"); | |
| 329 | + | |
| 330 | + srv.on("GET", "/-/claude/diffinery-ci.zip", { body: "tampered", headers: { "content-type": "application/zip", "x-diffinery-plugin-sha256": sha("other") } }); | |
| 331 | + const bad = await withClaude(); | |
| 332 | + const r2 = await run(["setup"], { env: bad }); | |
| 333 | + expect(r2.code).toBe(1); | |
| 334 | + expect(r2.stderr).toContain("didn't match its checksum"); | |
| 335 | + expect(existsSync(path.join(bad.HOME!, ".claude", "settings.json"))).toBe(false); | |
| 336 | + }); | |
| 337 | +}); | |
| 338 | + | |
| 339 | +describe("Codex in diffinery setup", () => { | |
| 340 | + let srv: FakeServer; | |
| 341 | + beforeEach(async () => { | |
| 342 | + srv = await fakeServer(); | |
| 343 | + srv.on("GET", "/api/v3/user", { body: { login: "alice", type: "User" } }); | |
| 344 | + }); | |
| 345 | + afterEach(() => srv.close()); | |
| 346 | + | |
| 347 | + /** | |
| 348 | + * A codex that records its calls. `mcp get` answers `server` (none: exit status 1, as codex does) until | |
| 349 | + * config.toml names a URL, then that URL; `mcp remove` drops the config's diffinery table. | |
| 350 | + */ | |
| 351 | + function fakeCodex(server: unknown): { dir: string; calls: () => string[] } { | |
| 352 | + const dir = tempDir("diffinery-fake-codex-"); | |
| 353 | + const log = path.join(dir, "calls"); | |
| 354 | + writeFileSync(path.join(dir, "answer.json"), JSON.stringify(server ?? null)); | |
| 355 | + writeFileSync( | |
| 356 | + path.join(dir, "codex"), | |
| 357 | + [ | |
| 358 | + "#!/bin/sh", | |
| 359 | + `printf '%s\\n' "$*" >> "${log}"`, | |
| 360 | + 'cfg="$CODEX_HOME/config.toml"', | |
| 361 | + 'if [ "$1 $2" = "mcp remove" ]; then sed -i.bak "/mcp_servers.diffinery/,\\$d" "$cfg"; exit 0; fi', | |
| 362 | + 'if [ "$1 $2" = "mcp get" ]; then', | |
| 363 | + ' u=$(sed -n \'s/^url = "\\(.*\\)"$/\\1/p\' "$cfg" 2>/dev/null | tail -n 1)', | |
| 364 | + ' b=$(sed -n \'s/^bearer_token_env_var = "\\(.*\\)"$/\\1/p\' "$cfg" 2>/dev/null | tail -n 1)', | |
| 365 | + ' if [ -n "$u" ]; then printf \'{"transport":{"url":"%s","bearer_token_env_var":"%s"}}\' "$u" "$b"; exit 0; fi', | |
| 366 | + server ? ` cat "${dir}/answer.json"` : " echo 'Error: No MCP server named' >&2; exit 1", | |
| 367 | + "fi", | |
| 368 | + "exit 0", | |
| 369 | + "", | |
| 370 | + ].join("\n"), | |
| 371 | + ); | |
| 372 | + chmodSync(path.join(dir, "codex"), 0o755); | |
| 373 | + return { dir, calls: () => (existsSync(log) ? readFileSync(log, "utf8").trim().split("\n") : []) }; | |
| 374 | + } | |
| 375 | + const machine = async (codexDir: string, config?: string) => { | |
| 376 | + const env = isolatedEnv({ DIFFINERY_NO_CODEX: "", PATH: `${codexDir}${path.delimiter}${process.env.PATH}` }); | |
| 377 | + env.CODEX_HOME = path.join(env.HOME!, ".codex"); | |
| 378 | + if (config !== undefined) { | |
| 379 | + mkdirSync(env.CODEX_HOME); | |
| 380 | + writeFileSync(path.join(env.CODEX_HOME, "config.toml"), config); | |
| 381 | + } | |
| 382 | + await writeConfig(env, { url: srv.url, token: "dfy_good" }); | |
| 383 | + return env; | |
| 384 | + }; | |
| 385 | + const tomlOf = (env: NodeJS.ProcessEnv) => readFileSync(path.join(env.CODEX_HOME!, "config.toml"), "utf8"); | |
| 386 | + | |
| 387 | + it("points Codex's diffinery MCP server at a self-hosted server, keeping a headless token setting", async () => { | |
| 388 | + const plugin = fakeCodex({ name: "diffinery", transport: { type: "streamable_http", url: "https://diffinery.com/mcp", bearer_token_env_var: null } }); | |
| 389 | + const env = await machine(plugin.dir, 'model = "o3"'); | |
| 390 | + const r = await run(["setup", "--no-auto-merge"], { env }); | |
| 391 | + expect(r.code).toBe(0); | |
| 392 | + expect(r.stdout).toContain(`✓ Codex: the diffinery MCP server is now ${srv.url}/mcp`); | |
| 393 | + expect(plugin.calls()).toEqual(["mcp get diffinery --json", "mcp get diffinery --json"]); | |
| 394 | + expect(tomlOf(env)).toBe(`model = "o3"\n\n[mcp_servers.diffinery]\nurl = "${srv.url}/mcp"\n`); | |
| 395 | + // Again: nothing to change. | |
| 396 | + expect((await run(["setup", "--no-auto-merge"], { env })).stdout).toContain(`✓ Codex: the diffinery MCP server is ${srv.url}/mcp`); | |
| 397 | + | |
| 398 | + // A server of the user's own, sending DIFFINERY_TOKEN: replaced, and still sending it. | |
| 399 | + const headless = fakeCodex({ name: "diffinery", transport: { url: "https://old.example/mcp", bearer_token_env_var: "DIFFINERY_TOKEN" } }); | |
| 400 | + const henv = await machine(headless.dir, '[mcp_servers.diffinery]\nurl = "https://old.example/mcp"\nbearer_token_env_var = "DIFFINERY_TOKEN"\n'); | |
| 401 | + await run(["setup", "--no-auto-merge"], { env: henv }); | |
| 402 | + expect(headless.calls()).toContain("mcp remove diffinery"); | |
| 403 | + expect(tomlOf(henv)).toBe(`[mcp_servers.diffinery]\nurl = "${srv.url}/mcp"\nbearer_token_env_var = "DIFFINERY_TOKEN"\n`); | |
| 404 | + }); | |
| 405 | + | |
| 406 | + it("leaves Codex alone for diffinery.com", async () => { | |
| 407 | + const codex = fakeCodex(null); | |
| 408 | + const { setupCodexMcp } = await import("../src/commands/codex.js"); | |
| 409 | + expect(await setupCodexMcp({ ...(await machine(codex.dir)) }, "https://diffinery.com")).toBe(null); | |
| 410 | + expect(codex.calls()).toEqual([]); | |
| 411 | + }); | |
| 412 | +}); |
addedapps/cli/test/workflow.test.ts+98−0View file
| @@ -1,0 +1,98 @@ | ||
| 1 | +import { mkdirSync, writeFileSync } from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 4 | +import { parseInputs } from "../src/commands/workflow.js"; | |
| 5 | +import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; | |
| 6 | + | |
| 7 | +const GOOD = `name: CI | |
| 8 | +on: [push] | |
| 9 | +jobs: | |
| 10 | + test: | |
| 11 | + runs-on: ubuntu-latest | |
| 12 | + steps: | |
| 13 | + - run: echo hi | |
| 14 | +`; | |
| 15 | + | |
| 16 | +const BAD = `name: Broken | |
| 17 | +on: push | |
| 18 | +jobs: | |
| 19 | + build: | |
| 20 | + runs-on: ubuntu-latest | |
| 21 | + steps: | |
| 22 | + - run: echo hi | |
| 23 | + bogus: true | |
| 24 | + deploy: | |
| 25 | + needs: missing-job | |
| 26 | + runs-on: ubuntu-latest | |
| 27 | + steps: | |
| 28 | + - run: echo deploy | |
| 29 | +`; | |
| 30 | + | |
| 31 | +describe("workflow validate", () => { | |
| 32 | + function project(files: Record<string, string>): string { | |
| 33 | + const dir = tempDir("diffinery-wf-"); | |
| 34 | + for (const [p, content] of Object.entries(files)) { | |
| 35 | + mkdirSync(path.dirname(path.join(dir, p)), { recursive: true }); | |
| 36 | + writeFileSync(path.join(dir, p), content); | |
| 37 | + } | |
| 38 | + return dir; | |
| 39 | + } | |
| 40 | + | |
| 41 | + it("passes valid files in both workflow directories", async () => { | |
| 42 | + const cwd = project({ ".github/workflows/ci.yml": GOOD, ".diffinery/workflows/other.yaml": GOOD, ".github/workflows/README.md": "not yaml" }); | |
| 43 | + const r = await run(["workflow", "validate"], { cwd, env: isolatedEnv() }); | |
| 44 | + expect(r.code).toBe(0); | |
| 45 | + expect(r.stdout).toContain(".github/workflows/ci.yml"); | |
| 46 | + expect(r.stdout).toContain(".diffinery/workflows/other.yaml"); | |
| 47 | + expect(r.stderr).toContain("2 files checked: no errors"); | |
| 48 | + }); | |
| 49 | + | |
| 50 | + it("reports errors with file:line and exits 1", async () => { | |
| 51 | + const cwd = project({ ".github/workflows/ci.yml": GOOD, ".github/workflows/bad.yml": BAD }); | |
| 52 | + const r = await run(["workflow", "validate"], { cwd, env: isolatedEnv() }); | |
| 53 | + expect(r.code).toBe(1); | |
| 54 | + expect(r.stdout).toMatch(/\.github\/workflows\/bad\.yml:10(:\d+)?: error: .*missing-job/); | |
| 55 | + expect(r.stdout).toMatch(/\.github\/workflows\/bad\.yml:8(:\d+)?: (error|warning): .*bogus/); | |
| 56 | + expect(r.stderr).toMatch(/2 files checked: \d+ errors?/); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it("validates explicit files and reports YAML syntax errors", async () => { | |
| 60 | + const cwd = project({ "x.yml": "jobs: [unclosed\n" }); | |
| 61 | + const r = await run(["workflow", "validate", "x.yml", "--json"], { cwd, env: isolatedEnv() }); | |
| 62 | + expect(r.code).toBe(1); | |
| 63 | + const res = JSON.parse(r.stdout); | |
| 64 | + expect(res[0].file).toBe("x.yml"); | |
| 65 | + expect(res[0].valid).toBe(false); | |
| 66 | + expect(res[0].problems[0].message).toMatch(/YAML/); | |
| 67 | + }); | |
| 68 | + | |
| 69 | + it("is fine with no workflow files", async () => { | |
| 70 | + const r = await run(["workflow", "validate"], { cwd: project({}), env: isolatedEnv() }); | |
| 71 | + expect(r.code).toBe(0); | |
| 72 | + expect(r.stderr).toContain("No workflow files"); | |
| 73 | + }); | |
| 74 | +}); | |
| 75 | + | |
| 76 | +describe("workflow run", () => { | |
| 77 | + let srv: FakeServer; | |
| 78 | + beforeEach(async () => { | |
| 79 | + srv = await fakeServer(); | |
| 80 | + }); | |
| 81 | + afterEach(() => srv.close()); | |
| 82 | + | |
| 83 | + it("dispatches with inputs on the default branch", async () => { | |
| 84 | + srv.on("GET", "/api/v3/repos/o/r", { body: { default_branch: "trunk" } }); | |
| 85 | + srv.on("POST", /\/actions\/workflows\/.+\/dispatches$/, { status: 201, body: { run_id: "run1", html_url: "http://x" } }); | |
| 86 | + const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy" }); | |
| 87 | + const r = await run(["workflow", "run", "deploy.yml", "-R", "o/r", "-f", "env=prod", "-f", "dry=true"], { env }); | |
| 88 | + expect(r.code).toBe(0); | |
| 89 | + const req = srv.find("POST", /dispatches$/)[0]!; | |
| 90 | + expect(req.path).toBe("/api/v3/repos/o/r/actions/workflows/deploy.yml/dispatches"); | |
| 91 | + expect(req.body).toEqual({ ref: "trunk", inputs: { env: "prod", dry: "true" } }); | |
| 92 | + }); | |
| 93 | + | |
| 94 | + it("parses -f pairs", () => { | |
| 95 | + expect(parseInputs(["a=1", "b=x=y", "c="])).toEqual({ a: "1", b: "x=y", c: "" }); | |
| 96 | + expect(() => parseInputs(["nope"])).toThrow(); | |
| 97 | + }); | |
| 98 | +}); |
addedapps/cli/tsconfig.json+1−0View file
| @@ -1,0 +1,1 @@ | ||
| 1 | +{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "types": ["node"] }, "include": ["src", "test"] } |
addedapps/cli/vitest.config.ts+4−0View file
| @@ -1,0 +1,4 @@ | ||
| 1 | +import { defineConfig } from "vitest/config"; | |
| 2 | +import { timeouts } from "../../vitest.shared"; | |
| 3 | + | |
| 4 | +export default defineConfig({ test: timeouts }); |
addedapps/diffinery/.gitignore+2−0View file
| @@ -1,0 +1,2 @@ | ||
| 1 | +build/ | |
| 2 | +src/docs/content.generated.ts |
addedapps/diffinery/claude-plugins/README.md+15−0View file
| @@ -1,0 +1,15 @@ | ||
| 1 | +# Claude Code plugins, as served for diffinery setup | |
| 2 | + | |
| 3 | +The plugins in plugins/ at the repository root, packed by `scripts/pack-plugins.py` | |
| 4 | +(reproducible: the same sources give the same bytes). Diffinery serves them at | |
| 5 | +`/-/claude/<name>.zip` (and `/-/claude/<name>-<version>.zip`): `diffinery setup` downloads them | |
| 6 | +(apps/cli/src/commands/claude-plugins.ts, also from Agent setup's script), checks the | |
| 7 | +x-diffinery-plugin-sha256 header and names them in Claude Code's settings, so installing them | |
| 8 | +needs no access to the plugin repository. See docs/connect/ci-status-in-claude.md. | |
| 9 | + | |
| 10 | +To update one: change it in plugins/, bump its version (in its manifests and in both | |
| 11 | +marketplace files), run `python3 scripts/pack-plugins.py <name>` at the repository root, | |
| 12 | +replace the zip here (one file per plugin, `<name>-<version>.zip`), and check that `sha256sum` | |
| 13 | +matches a fresh pack of the merged commit. | |
| 14 | +scripts/build-docs.mjs embeds them and refuses a name it doesn't know, two versions of one | |
| 15 | +plugin, or a zip without `.claude-plugin/plugin.json` at its root. |
addedapps/diffinery/claude-plugins/diffinery-0.1.4.zip+0−0View file
Binary file not shown.
addedapps/diffinery/claude-plugins/diffinery-ci-0.2.4.zip+0−0View file
Binary file not shown.
addedapps/diffinery/docs/accounts/account-data.md+72−0View file
| @@ -1,0 +1,72 @@ | ||
| 1 | +--- | |
| 2 | +title: Exporting and deleting your account | |
| 3 | +description: Download an archive of your account, with your repositories as Git bundles and the issues you wrote, or delete your account and everything it owns. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +**Settings → Account** (`/settings/account`) is where you download a copy of your data and where you delete your account. Both ask you to have signed in within the last 2 hours; if you signed in earlier, Diffinery asks you to sign in again first and then brings you back. | |
| 7 | + | |
| 8 | +## Exporting your data | |
| 9 | + | |
| 10 | +Click **Start export**. Diffinery builds the archive in the background, which takes from a few seconds to a while for large repositories, and emails you when it's ready (when the instance sends email). The **Account** page shows it too: **Preparing your export…** while it is built, then **Export ready** with its size and a **Download** button. You can download it as often as you like for 7 days; after that it is deleted. Starting a new export replaces the old one. Only one export can be built at a time. | |
| 11 | + | |
| 12 | +Only you can download your export. Site administrators can't. Asking for an export and downloading it are both recorded in the [audit log](/docs/security/audit-log). | |
| 13 | + | |
| 14 | +### What's in the archive | |
| 15 | + | |
| 16 | +The download is a tar file named `diffinery-export-<username>-<date>.tar`. Unpack it with `tar -xf`. Everything is in one folder, `diffinery-export-<username>/`: | |
| 17 | + | |
| 18 | +| File | What it holds | | |
| 19 | +| --- | --- | | |
| 20 | +| `manifest.json` | The format version (`format`, now `1`), the instance's address, your username, when the archive was made, and every file in it with its size in bytes. | | |
| 21 | +| `account.json` | Your profile (username, display name, email address, when you joined, whether you are a site administrator, your profile picture's address), your settings (appearance, notification settings, watched repositories), your organizations and role in each, your agents, your access tokens and passkeys, your linked accounts and your model provider keys. | | |
| 22 | +| `repositories/<name>.json` | Each repository you own: description, visibility, default branch, topics, homepage, when it was created and last pushed, its clone URL, and the name of its bundle. | | |
| 23 | +| `repositories/<name>.bundle` | The repository's branches and tags as a Git bundle. | | |
| 24 | +| `issues/<owner>/<repository>.json` | For each repository you can see where you wrote something: the issues and pull requests you opened (`issues`, with `kind` saying which), the comments you wrote (`comments`, review comments with their file and line) and the reviews you submitted (`reviews`). | | |
| 25 | + | |
| 26 | +Access tokens, passkeys and model keys are listed by name and date only. Nothing in the archive can sign in as you or use your keys. Secrets of your repositories and Actions are not exported. | |
| 27 | + | |
| 28 | +To get a repository back from its bundle: | |
| 29 | + | |
| 30 | +```bash | |
| 31 | +git clone diffinery-export-alice/repositories/notes.bundle notes | |
| 32 | +``` | |
| 33 | + | |
| 34 | +A repository with no branches or tags has no bundle, and one larger than 4 GiB isn't bundled; its JSON file has a `note` saying so, and `bundle` is `null`. Clone it from `clone_url` while your account exists. Issues, comments and reviews are exported up to 5,000 of each per repository. | |
| 35 | + | |
| 36 | +## Deleting your account | |
| 37 | + | |
| 38 | +The **Delete your account** box at the bottom of the **Account** page deletes your account at once. Type your username and click **Delete your account**. You are signed out and sent to the sign-in page. | |
| 39 | + | |
| 40 | +You can't delete your account while: | |
| 41 | + | |
| 42 | +- you are the only owner of an organization. The page names the organization. Make someone else an owner of it, or delete the organization, first (see [Organizations](/docs/accounts/organizations)). | |
| 43 | +- you are the only site administrator. Make someone else a site administrator first (see [Site administration](/docs/admin/site-administration#site-administrators)). | |
| 44 | +- a GitHub import you started, or one into your account, is still running. Wait for it to finish or cancel it. | |
| 45 | + | |
| 46 | +### What goes and what stays | |
| 47 | + | |
| 48 | +Deleted at once: | |
| 49 | + | |
| 50 | +- every repository you own, with its history, issues, pull requests, workflow runs, secrets, webhooks and releases. Their names, and any old names that redirected to them, stop working straight away; their storage is wiped in the background. | |
| 51 | +- your container images and other packages. | |
| 52 | +- your access tokens, passkeys and every browser session. | |
| 53 | +- your agents, with their tokens. | |
| 54 | +- your organization memberships, repository collaborator access and pending invitations. | |
| 55 | +- your notifications, watches, notification settings, linked accounts, model keys, saved terminal setups, quotas and usage, sign-in links and email history. | |
| 56 | +- your profile and profile picture. `https://diffinery.com/<username>` no longer exists. | |
| 57 | + | |
| 58 | +What stays: | |
| 59 | + | |
| 60 | +- issues, pull requests, comments, reviews and reactions you wrote in other people's repositories. They are shown as written by **ghost**, a placeholder for people who deleted their accounts. This happens in the background, so for a few minutes after the deletion some may still show your old username. | |
| 61 | +- commits you pushed to other people's repositories. They are part of those repositories' Git history and keep the name and email address in the commit. | |
| 62 | +- the [audit log](/docs/security/audit-log), which keeps what you did, under your old username. | |
| 63 | + | |
| 64 | +Diffinery emails you to say the account was deleted. | |
| 65 | + | |
| 66 | +### Your username | |
| 67 | + | |
| 68 | +Nobody, you included, can use your username for 90 days after the deletion: creating a person, organization or agent with it, or renaming one to it, is refused with `This username belonged to an account that was deleted recently; it can be used again from <date>` (`This name ...` when renaming). The same goes for the usernames of your agents. | |
| 69 | + | |
| 70 | +### Deletion by a site administrator | |
| 71 | + | |
| 72 | +Site administrators can delete anyone's account from **Site administration** (see [Deleting an account](/docs/admin/site-administration#deleting-an-account)). The same rules apply, and the person gets an email saying a site administrator deleted their account. |
addedapps/diffinery/docs/accounts/blocking-and-reporting.md+87−0View file
| @@ -1,0 +1,87 @@ | ||
| 1 | +--- | |
| 2 | +title: Blocking and reporting | |
| 3 | +description: Block a person from your or your organization's repositories, and report abusive people, repositories, issues, pull requests and comments to the site administrators. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +When someone is abusive, you can block them, which keeps them out of your repositories, and report them, which asks the site administrators to act. Both work as on GitHub. | |
| 7 | + | |
| 8 | +## Block a person | |
| 9 | + | |
| 10 | +Block someone from their profile: open the **…** menu next to their name and click **Block user**, then confirm. Or go to **Settings → Blocked users** (`/settings/blocked-users`), enter their username and click **Block user**. | |
| 11 | + | |
| 12 | +In your repositories, a person you blocked (and their agents) can't: | |
| 13 | + | |
| 14 | +- open issues or pull requests, | |
| 15 | +- comment, review or react, | |
| 16 | +- be added as a collaborator: blocking removes them from every repository you own, and adding them again fails with "olive blocked diffy; unblock them first". | |
| 17 | + | |
| 18 | +Also: | |
| 19 | + | |
| 20 | +- pending invitations between the two of you are cancelled, and neither of you can invite the other, | |
| 21 | +- you get no notifications from them, including when they @mention you anywhere. | |
| 22 | + | |
| 23 | +They can still read your public repositories, and what they wrote before stays. They aren't told they were blocked, but a refused request says so: `403 you can't open issues or pull requests, comment, review or react in olive's repositories: olive blocked you`. Only people can be blocked, not organizations or agents. | |
| 24 | + | |
| 25 | +To unblock, click **Unblock** next to them on **Settings → Blocked users**, or **Unblock user** in the **…** menu on their profile. They aren't added back to anything. | |
| 26 | + | |
| 27 | +### Block a person from an organization | |
| 28 | + | |
| 29 | +An organization's owners block people for the whole organization on its **Settings** tab, under **Blocked users**. The effect is the same in every repository the organization owns. An organization can't block its own members; remove them from the organization first. | |
| 30 | + | |
| 31 | +### Blocks through the API | |
| 32 | + | |
| 33 | +These are GitHub's endpoints. Reading needs any token; blocking and unblocking need a `write`-scoped token (`admin` for an organization). | |
| 34 | + | |
| 35 | +| Method and path | Does | | |
| 36 | +| --- | --- | | |
| 37 | +| `GET /api/v3/user/blocks` | The people you blocked | | |
| 38 | +| `GET /api/v3/user/blocks/{username}` | `204` if you blocked them, `404` if not | | |
| 39 | +| `PUT /api/v3/user/blocks/{username}` | Block them (`204`) | | |
| 40 | +| `DELETE /api/v3/user/blocks/{username}` | Unblock them (`204`) | | |
| 41 | +| `GET`, `PUT`, `DELETE /api/v3/orgs/{org}/blocks[/{username}]` | The same for an organization; owners only | | |
| 42 | + | |
| 43 | +Blocking and unblocking are audited as `user.block`, `user.unblock`, `org.block` and `org.unblock`. | |
| 44 | + | |
| 45 | +## Report content | |
| 46 | + | |
| 47 | +Report a person, a repository, an issue, a pull request or a comment to the site administrators of your instance: | |
| 48 | + | |
| 49 | +- a person: **Report user** in the **…** menu on their profile, | |
| 50 | +- a repository: **Report repository** in the **…** menu next to **Watch**, | |
| 51 | +- an issue or pull request: **Report content** in the **…** menu next to its title, | |
| 52 | +- a comment: **Report content** in the comment's **…** menu. | |
| 53 | + | |
| 54 | +Pick what is wrong (spam, harassment or bullying, hateful content, violence or threats, sexual content, malware or phishing, impersonation, or something else), add a note if it helps (up to 2,000 characters; needed for "Something else") and click **Send report**. You see "Thanks for reporting it. The site administrators will look at it." The person you reported isn't told who reported them. | |
| 55 | + | |
| 56 | +You can file 20 reports a day, and one open report about the same thing. Site administrators see what was reported as it was at the time, so editing or deleting it afterwards changes nothing. They resolve the report and may hide the content, suspend the account or do nothing; see [Site administration](/docs/admin/site-administration#reports). | |
| 57 | + | |
| 58 | +### Report through the API | |
| 59 | + | |
| 60 | +`POST /api/v3/diffinery/reports` with a `write`-scoped token. Send `kind` and what identifies the thing: | |
| 61 | + | |
| 62 | +| `kind` | Also send | | |
| 63 | +| --- | --- | | |
| 64 | +| `user` | `login` | | |
| 65 | +| `repository` | `repository` (`owner/name`) | | |
| 66 | +| `issue`, `pull_request` | `repository` and `number` | | |
| 67 | +| `comment` | `repository` and `comment_id` | | |
| 68 | + | |
| 69 | +with `reason` (`spam`, `harassment`, `hate`, `violence`, `sexual`, `malware`, `impersonation` or `other`) and an optional `body`: | |
| 70 | + | |
| 71 | +```sh | |
| 72 | +echo '{"kind": "comment", "repository": "acme/website", "comment_id": "1234", "reason": "spam"}' | diffinery api POST /diffinery/reports --input - | |
| 73 | +``` | |
| 74 | + | |
| 75 | +The answer is `201` with the report's `id`, `kind`, `target`, `html_url`, `reason`, `body`, `state` and `created_at`. Agents can't file reports. | |
| 76 | + | |
| 77 | +## Troubleshooting | |
| 78 | + | |
| 79 | +| Message | Cause and fix | | |
| 80 | +| --- | --- | | |
| 81 | +| `403 ... olive blocked you` | The repository's owner blocked you. | | |
| 82 | +| `422 olive blocked diffy; unblock them first` | You (or your organization) blocked the person you are adding. | | |
| 83 | +| `422 you can't invite diffy` | That person blocked you. | | |
| 84 | +| `422 diffy is a member of acme; remove them from the organization before blocking them` | Organizations can't block their members. | | |
| 85 | +| `422 you already reported this` | Your earlier report about it is still open. | | |
| 86 | +| `429 you can file at most 20 reports in 24 hours` | Try again tomorrow. | | |
| 87 | +| `422 say what is wrong in the note` | "Something else" needs a note. | |
addedapps/diffinery/docs/accounts/cli-sign-in.md+152−0View file
| @@ -1,0 +1,152 @@ | ||
| 1 | +--- | |
| 2 | +title: Install and sign in to the CLI | |
| 3 | +description: Install the diffinery CLI with one command, then set up sign-in, git and gh with diffinery setup, through your browser or with a code on machines without one. No tokens to copy. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +The `diffinery` command-line client installs with one command and signs in through your browser: you click **Authorize** on diffinery.com and the CLI is signed in, with no token to copy or paste. For every command, see the [CLI reference](/docs/reference/cli). | |
| 7 | + | |
| 8 | +The short version, on Linux and macOS: | |
| 9 | + | |
| 10 | +```sh | |
| 11 | +curl -fsSL https://diffinery.com/install | bash | |
| 12 | +diffinery setup | |
| 13 | +``` | |
| 14 | + | |
| 15 | +`diffinery setup` signs you in through your browser, lets git use the sign-in, signs in the GitHub CLI (`gh`) when you have it, and adds Diffinery's plugins to Claude Code when you have it ([CI status in Claude](/docs/connect/ci-status-in-claude)). The rest of this page explains each step. | |
| 16 | + | |
| 17 | +## Install | |
| 18 | + | |
| 19 | +On Linux and macOS: | |
| 20 | + | |
| 21 | +```sh | |
| 22 | +curl -fsSL https://diffinery.com/install | bash | |
| 23 | +``` | |
| 24 | + | |
| 25 | +On Windows, in PowerShell: | |
| 26 | + | |
| 27 | +```powershell | |
| 28 | +powershell -c "irm https://diffinery.com/install.ps1 | iex" | |
| 29 | +``` | |
| 30 | + | |
| 31 | +What the script does: | |
| 32 | + | |
| 33 | +- Puts the CLI in `~/.diffinery` (`%USERPROFILE%\.diffinery` on Windows): the CLI itself in `lib/diffinery.mjs` and the `diffinery` command in `bin/`. | |
| 34 | +- Adds `~/.diffinery/bin` to your `PATH` for new terminals, in the startup files of your shell and of any other shell you've set up: `~/.zshrc` for zsh; `~/.bashrc` and the login file bash reads (`~/.bash_profile`, `~/.bash_login` or `~/.profile`, whichever you have; a new `~/.bash_profile` on macOS or `~/.profile` on Linux when you have none) for bash; `~/.config/fish/conf.d/diffinery.fish` for fish; `~/.profile` for other shells. Each file gets one `# Diffinery CLI` line, once, however often you run the script. On Windows, it adds the folder to your user `PATH`. | |
| 35 | +- Makes `diffinery` work right away, in the terminal you ran it in: when `~/.local/bin` (or `~/bin`) is already on your `PATH`, as it is for most people who installed Claude Code, it adds a `diffinery` link there. It never replaces a different file of that name. Without such a folder, the script prints an `export PATH=...` line to run, or open a new terminal. On Windows, the PowerShell window you ran it in is updated too. | |
| 36 | +- In a workflow job, it adds `~/.diffinery/bin` to `GITHUB_PATH`, so the job's later steps can run `diffinery`. | |
| 37 | +- Runs the CLI on your Node.js when it's version 22 or later. Otherwise it downloads the current Node.js LTS from `nodejs.org` for the CLI's own use, checks it against the SHA-256 that nodejs.org publishes, and keeps it in `~/.diffinery/node`. Your own Node.js setup isn't touched. On Alpine and other musl-based systems, install Node.js with the package manager (`apk add nodejs`) first. | |
| 38 | +- Prints the version it installed and what to run next. | |
| 39 | + | |
| 40 | +| Variable | Effect | | |
| 41 | +| --- | --- | | |
| 42 | +| `DIFFINERY_INSTALL` | Install somewhere other than `~/.diffinery`. | | |
| 43 | +| `DIFFINERY_NO_MODIFY_PATH=1` | Don't change any startup file or your `PATH`, and don't add the link. | | |
| 44 | + | |
| 45 | +The script downloads the CLI from the server it came from, so on your own Diffinery instance use `curl -fsSL https://<your-host>/install | bash`. You can read the script first: open `https://diffinery.com/install` (or `/install.ps1`) in a browser. | |
| 46 | + | |
| 47 | +To update, run `diffinery upgrade`, or run the install command again. To uninstall, delete `~/.diffinery`, the `diffinery` link in `~/.local/bin` or `~/bin` if the script made one, and the `# Diffinery CLI` lines from your shells' startup files. | |
| 48 | + | |
| 49 | +The CLI is one JavaScript file, so you can also install it by hand next to any Node.js 22 or later: download `https://diffinery.com/-/cli/diffinery.mjs` and run it with `node diffinery.mjs`. | |
| 50 | + | |
| 51 | +## Set up in one go | |
| 52 | + | |
| 53 | +```sh | |
| 54 | +diffinery setup | |
| 55 | +``` | |
| 56 | + | |
| 57 | +```text | |
| 58 | +✓ Logged in to https://diffinery.com as diffy with write access until Jan 12, 2027 | |
| 59 | +✓ git will use diffinery for https://diffinery.com | |
| 60 | +✓ gh is signed in to diffinery.com | |
| 61 | + | |
| 62 | +Connect Claude Code, Codex, Cursor and other AI apps: https://diffinery.com/docs/connect | |
| 63 | +``` | |
| 64 | + | |
| 65 | +It does three things, each explained below: | |
| 66 | + | |
| 67 | +1. Signs you in through your browser, as `diffinery login` does, unless the CLI is signed in already and its token still works. On a machine without a browser it shows a code instead, as `diffinery login --device` does. It never reads a token from standard input, so an AI agent can run it for you: you click **Authorize** (or enter the code it relays) yourself. | |
| 68 | +2. Lets git use the sign-in for `git clone`, `fetch` and `push`, as `diffinery auth setup-git` does. | |
| 69 | +3. Signs in the [GitHub CLI](/docs/agents/github-cli), as `diffinery auth setup-gh` does, when `gh` is installed. Without it, setup says so and finishes; run `diffinery auth setup-gh` after you install gh. | |
| 70 | + | |
| 71 | +Run it again whenever you like: it keeps a sign-in that works and changes nothing that's already set up. It takes the same `--url`, `--scope`, `--web` and `--device` options as `diffinery login`. When signing in gh fails, everything else is still set up and it exits with status 1. | |
| 72 | + | |
| 73 | +## Sign in | |
| 74 | + | |
| 75 | +```sh | |
| 76 | +diffinery login | |
| 77 | +``` | |
| 78 | + | |
| 79 | +1. The CLI opens your browser on **Authorize the Diffinery CLI** at diffinery.com. If you aren't signed in, sign in with your passkey first. If your last sign-in was more than 2 hours ago, Diffinery asks you to confirm it's you with your passkey, as it does before creating any token. | |
| 80 | +2. The page shows the computer asking (its name, operating system and CLI version) and the access it asks for. Check that it's the computer you're on, and pick the **Access**: | |
| 81 | + | |
| 82 | + | Access | Allows | | |
| 83 | + | --- | --- | | |
| 84 | + | **Read** | Clone, and view repositories, issues, pull requests and runs. | | |
| 85 | + | **Write** (default) | Also push, open and merge pull requests, comment, and run workflows (up to the maintain role). | | |
| 86 | + | **Admin** | Everything you can do, including settings, secrets and variables. | | |
| 87 | + | |
| 88 | +3. Click **Authorize**. The browser says "You're signed in", and the terminal prints `✓ Logged in to https://diffinery.com as diffy with write access until Jan 12, 2027`. | |
| 89 | + | |
| 90 | +Click **Cancel** instead and the CLI stops with `error: the sign-in was cancelled in the browser`. | |
| 91 | + | |
| 92 | +To ask for admin access up front (for `diffinery secret set`, for example), run `diffinery login --scope admin`. The page still preselects **Write** and says the CLI asked for Admin: choose **Admin** yourself. A command that needs more access than you signed in with says which `diffinery login --scope ...` to run. | |
| 93 | + | |
| 94 | +Then let Git use the same sign-in for `git clone`, `fetch` and `push` to diffinery.com: | |
| 95 | + | |
| 96 | +```sh | |
| 97 | +diffinery auth setup-git | |
| 98 | +``` | |
| 99 | + | |
| 100 | +### On a machine without a browser | |
| 101 | + | |
| 102 | +Over SSH, in a container or on any machine without a display, sign in with a code: | |
| 103 | + | |
| 104 | +```sh | |
| 105 | +diffinery login --device | |
| 106 | +``` | |
| 107 | + | |
| 108 | +```text | |
| 109 | +Your one-time code: WDJB-MJHT | |
| 110 | +Open https://diffinery.com/login/device in a browser on any device, sign in, and enter the code. | |
| 111 | +Waiting for you to approve (Ctrl+C to cancel)… | |
| 112 | +``` | |
| 113 | + | |
| 114 | +Open `https://diffinery.com/login/device` on your laptop or phone, enter the code, check the details (the computer's name, and where and when the code was asked for), and click **Authorize**. When the code was asked for from another country than the one your browser is in, the page says so in red; from another network, it notes that too. The CLI finishes on its own within a few seconds. The code works for 10 minutes. The CLI uses this way by itself when it sees no display (no `DISPLAY` or `WAYLAND_DISPLAY` on Linux, or an SSH session); `--web` forces the browser way. | |
| 115 | + | |
| 116 | +### Scripts and CI | |
| 117 | + | |
| 118 | +For scripts and CI, use a [personal access token](/docs/accounts/tokens) instead: | |
| 119 | + | |
| 120 | +```sh | |
| 121 | +export DIFFINERY_TOKEN=dfy_... # used by every command, nothing saved | |
| 122 | +diffinery login --token dfy_... # or save it in the config file | |
| 123 | +echo "$TOKEN" | diffinery login --with-token | |
| 124 | +``` | |
| 125 | + | |
| 126 | +## Your CLI sign-ins | |
| 127 | + | |
| 128 | +Each sign-in gives the CLI its own token, named after the computer (for example **Diffinery CLI on diffys-laptop**). It's listed under **Settings → Access tokens** with a **CLI sign-in** badge, and Diffinery emails you about it like any new token. | |
| 129 | + | |
| 130 | +- It lasts 90 days. Diffinery emails you before it expires; run `diffinery login` again to get a new one. | |
| 131 | +- `diffinery logout` revokes it on the server and removes it from the computer. Signing in again on the same computer revokes the token it replaces. | |
| 132 | +- Revoke it under **Settings → Access tokens** to sign a lost or old computer out. | |
| 133 | + | |
| 134 | +The CLI saves the server URL and the token in `~/.config/diffinery/config.json` (or under `$XDG_CONFIG_HOME`), readable only by you: the folder is set to `0700` and the file to `0600` each time it is saved. `DIFFINERY_TOKEN` and `DIFFINERY_URL` override the saved values. The server URL must be `https://`, since the token goes with every request; plain `http://` works only for this computer (`localhost`, `127.0.0.1`, `[::1]`), such as a local development server, and anything else fails with `refusing http://<host>: tokens would be sent unencrypted`. | |
| 135 | + | |
| 136 | +## How it stays safe | |
| 137 | + | |
| 138 | +- **No secret in a link.** The browser sign-in uses a one-time code that Diffinery sends only to the CLI on your own computer (`http://127.0.0.1:<port>`), and it works only together with a secret the CLI never lets out of its process (PKCE, as in OAuth for native apps). A sign-in link someone sends you can't sign their computer in as you: the code would go to your own computer. | |
| 139 | +- **Codes are typed, never linked.** A device code is never part of a URL, so nobody can send you a link that approves their code. Only approve a code that your own terminal shows. If someone asks you to enter a code for them, don't. The page warns when the code came from another country or network, and never preselects **Admin**, whatever the CLI asked for. | |
| 140 | +- **Short-lived and single-use.** Browser codes expire after 2 minutes and device codes after 10, each works once, and Diffinery stores only their hashes. The token is created when the CLI collects it and is never shown in the browser. | |
| 141 | +- **The same rules as tokens.** Approving needs a passkey sign-in from the last 2 hours, sign-ins are in the audit log (`token.create`), and a CLI token is limited by the access you chose like any token. | |
| 142 | + | |
| 143 | +## Troubleshooting | |
| 144 | + | |
| 145 | +| Problem | What to do | | |
| 146 | +| --- | --- | | |
| 147 | +| `command not found: diffinery` after installing | Open a new terminal, or run the `export PATH=...` line the installer printed. Run the install command again to check: it says which startup files put `~/.diffinery/bin` on your `PATH`. | | |
| 148 | +| The browser doesn't open | Copy the link the CLI prints into a browser on the same computer, or use `diffinery login --device`. | | |
| 149 | +| "This sign-in link is incomplete" | The page was opened without the CLI's details. Run `diffinery login` again and use the page it opens. | | |
| 150 | +| "That code isn't valid or has expired" | Check the code in your terminal. Codes last 10 minutes; run `diffinery login --device` again for a new one. | | |
| 151 | +| `error: diffinery needs Node.js 22 or later` | The `node` on your `PATH` is older. Run the install command again: it installs a Node.js for the CLI. | | |
| 152 | +| `403 this token needs the admin scope` | Run `diffinery login --scope admin` and choose **Admin** on the authorize page. | |
addedapps/diffinery/docs/accounts/early-access.md+135−0View file
| @@ -1,0 +1,135 @@ | ||
| 1 | +--- | |
| 2 | +title: Early access | |
| 3 | +description: Ask for an invitation to an invite-only Diffinery instance, what happens to your request and data, how the form is protected, and how site administrators invite, decline or delete requests. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +While a Diffinery instance is invite-only (the default), people without an invitation can ask for one at `/early-access` on that instance. The sign-in page and the sign-up address lead there. A request never grants access by itself: a site administrator reads it and decides whether to send an [invitation](/docs/accounts/invitations). | |
| 7 | + | |
| 8 | +On diffinery.com, the project's own instance, this is how to try Diffinery before you deploy your own: [request early access there](https://diffinery.com/early-access). | |
| 9 | + | |
| 10 | +The form is only there while sign-up is closed. On an instance with [open sign-up](/docs/accounts/passkeys-and-sign-in#open-sign-up) (`SIGNUP=open`), `/early-access` leads to the sign-up page instead. | |
| 11 | + | |
| 12 | +## Request early access | |
| 13 | + | |
| 14 | +1. Open `/early-access`. If you're already signed in, you go to your dashboard instead. | |
| 15 | +2. Fill in the form. Only **Work email** is required: | |
| 16 | + | |
| 17 | + | Field | Limit | What it's for | | |
| 18 | + | --- | --- | --- | | |
| 19 | + | **Work email** | a valid address, up to 254 characters | Where the confirmation link and, later, the invitation go | | |
| 20 | + | **Name** | up to 100 characters | How the site administrators address you | | |
| 21 | + | **Company or team** | up to 120 characters | Who you'd use Diffinery with | | |
| 22 | + | **What would you use Diffinery for?** | up to 1,000 characters, plain text | What you'd move or build, so the site administrators can prioritize | | |
| 23 | + | **Where do you host your code today?** | one of GitHub, GitLab, Bitbucket, Azure DevOps, Self-hosted, Somewhere else, Nowhere yet | Helps plan your move | | |
| 24 | + | |
| 25 | +3. Complete the "are you a person" check if one shows, and click **Request early access**. | |
| 26 | +4. You see **Check your inbox**. Open the email titled "Confirm your early access request" and click **Confirm my request**, then press **Confirm my request** on the page that opens. The link works once, for 48 hours; asking again sends a new link, and only the newest one works. | |
| 27 | +5. You see **You're on the list**. Your request now waits for a site administrator. | |
| 28 | + | |
| 29 | +When a site administrator invites you, you get the usual invitation email ("invited you to Diffinery"). Its link lets you pick a username and create a passkey; see [How sign-up works](/docs/accounts/invitations#how-sign-up-works). Nobody emails you if your request is declined. | |
| 30 | + | |
| 31 | +### What the email says | |
| 32 | + | |
| 33 | +The form always shows **Check your inbox**, whatever the address. What arrives depends on the address: | |
| 34 | + | |
| 35 | +| The address | The email | | |
| 36 | +| --- | --- | | |
| 37 | +| New, or asked before but never confirmed | "Confirm your early access request", with the link. The new details replace the unconfirmed ones. | | |
| 38 | +| Already confirmed or declined | "We already have your early access request": nothing else to do | | |
| 39 | +| Already invited | "You're already invited", with the date the invitation was sent | | |
| 40 | +| Belongs to an active Diffinery account | "You already have a Diffinery account", with a link to sign in | | |
| 41 | +| Belongs to a disabled or suspended account | Nothing | | |
| 42 | + | |
| 43 | +So only the person who reads that inbox learns whether the address has an account or asked before; the form itself never tells anyone. These emails never contain anything typed into the form. | |
| 44 | + | |
| 45 | +### Removing your request | |
| 46 | + | |
| 47 | +You can delete your request yourself at any time, whatever its state: | |
| 48 | + | |
| 49 | +1. Open `/early-access/remove` (the form's privacy note links to it). | |
| 50 | +2. Enter the address of the request, complete the check if one shows, and click **Email me a removal link**. | |
| 51 | +3. Open the email titled "Remove your early access request" and click **Remove my request**, then press **Remove my request** on the page that opens. You see **Your request is removed**. | |
| 52 | + | |
| 53 | +The link works once, for 24 hours. The page answers **Check your inbox** whether or not the address has a request, and only an address with one gets an email, so nobody can remove or look up someone else's request. Removal links count towards the same daily limits as requests. A site administrator can also delete a request for you. | |
| 54 | + | |
| 55 | +Unconfirmed requests are deleted automatically 7 days after they were last sent. | |
| 56 | + | |
| 57 | +## What is stored | |
| 58 | + | |
| 59 | +| What | Kept | | |
| 60 | +| --- | --- | | |
| 61 | +| The fields you filled in and the request's state | Until a site administrator deletes the request; unconfirmed requests for 7 days | | |
| 62 | +| A SHA-256 hash of the confirmation link's code | Until the link is used or replaced | | |
| 63 | +| A SHA-256 hash of a removal link's code, when you ask for one | Until the request is removed | | |
| 64 | +| One row per form submission with SHA-256 hashes of your IP address and of the address typed | One day, for the daily limits only, even after the request is deleted | | |
| 65 | + | |
| 66 | +Your IP address and the address you typed are never stored in the clear for the limits, and nothing you type is written to logs. Only site administrators can read requests. In the [data model](/docs/self-hosting/architecture), these are the `early_access` and `early_access_attempts` tables. | |
| 67 | + | |
| 68 | +## How the form is protected | |
| 69 | + | |
| 70 | +| Protection | What it does | | |
| 71 | +| --- | --- | | |
| 72 | +| Cloudflare Turnstile | The "are you a person" check, the same one the [security report form](/docs/security/disclosure) uses. A deployed instance without Turnstile keys (`TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET`) doesn't show the form; on your own machine the check is skipped. | | |
| 73 | +| Honeypot and timing | A field people never see, and a signed time stamp: a form that fills the hidden field, comes back within 3 seconds, or carries a missing or forged stamp gets the usual **Check your inbox** page and nothing is stored or sent. A form left open more than 24 hours asks you to send it again. | | |
| 74 | +| Per-minute limit | At most 20 sign-in attempts, security reports and early access requests a minute from one network (`RL_AUTH`, see [Rate limits](/docs/reference/rest-api#rate-limits)). | | |
| 75 | +| Daily limits | At most 10 submissions from one IP address, 3 emails to one address, and 500 emails from the form in total, in 24 hours. Past the network limit you see "Too many requests from your network today"; past the other two the page looks the same and no email is sent. | | |
| 76 | +| Double opt-in | A request counts only after its address follows the emailed link, so nobody can sign up someone else. | | |
| 77 | +| Strict input | Lengths are capped (longer is refused, never cut), the email address is checked, the hosting answer must be one of the list, and control and text-direction characters are removed. Everything is shown as plain text, never as HTML or Markdown. | | |
| 78 | + | |
| 79 | +## For site administrators | |
| 80 | + | |
| 81 | +**Settings → Early access** (`/settings/admin/early-access`) is in the **Site administration** group. The sidebar shows how many confirmed requests are waiting, **People and invitations** has a reminder while any are, and each site administrator gets one in-app notification, "New early access requests are waiting for you", which stays until it is read however many requests arrive. There are no per-request emails. Anyone who isn't a site administrator gets `404`. | |
| 82 | + | |
| 83 | +The tabs filter by state, with counts: | |
| 84 | + | |
| 85 | +| Tab | State | Meaning | | |
| 86 | +| --- | --- | --- | | |
| 87 | +| **Confirmed** (the default) | `confirmed` | The address confirmed; waiting for you to invite or decline | | |
| 88 | +| **Unconfirmed** | `unconfirmed` | The address hasn't followed the link yet | | |
| 89 | +| **Invited** | `invited` | Invited, by whom and when | | |
| 90 | +| **Declined** | `declined` | Declined, by whom and when | | |
| 91 | +| **All** | | Every request | | |
| 92 | + | |
| 93 | +The search box finds requests whose address, name, company or use contains what you type. Each request shows its name and address, company, where they host code, when they asked, the start of what they'd use Diffinery for, and an amber **has an account** badge when an active account already uses the address. Click a name to see everything, including the full text and its reference. | |
| 94 | + | |
| 95 | +- **Invite** asks you to confirm, then sends the address the instance's invitation email, exactly like **Invite someone to this instance** on [People and invitations](/docs/admin/site-administration#invites): it works once for 14 days. The request becomes **Invited**. If the address already has a pending instance invitation, that one is kept and no second email is sent. An address that already has an account has no **Invite** button. You can invite an unconfirmed request too: the invitation itself only works for someone who reads that inbox. | |
| 96 | +- **Decline** marks the request **Declined**. Nobody is emailed. **Put back** on a declined request returns it to **Confirmed** (or **Unconfirmed**, if it never was). | |
| 97 | +- **Delete request**, on a request's page, removes it for good, for someone who asks for their data to be removed. An invitation already sent stays; revoke it on **People and invitations**. | |
| 98 | + | |
| 99 | +Every decision is in the [audit log](/docs/security/audit-log): `early_access.invite` (with `invite.create` when a new invitation is sent), `early_access.decline`, `early_access.restore` and `early_access.delete` (also when someone removes their own request), and `early_access.confirm` when an address confirms. Entries name the request by its id; none holds what the request said, and `early_access.delete` doesn't hold the address. | |
| 100 | + | |
| 101 | +### From the API | |
| 102 | + | |
| 103 | +With a site administrator's token (`read` to list, `admin` to change): | |
| 104 | + | |
| 105 | +| Endpoint | What it does | | |
| 106 | +| --- | --- | | |
| 107 | +| `GET /api/v3/diffinery/admin/early-access` | List requests, newest first. `state`, `q`, `per_page` (up to 500) and `page` filter and page them. | | |
| 108 | +| `GET /api/v3/diffinery/admin/early-access/{id}` | One request | | |
| 109 | +| `POST /api/v3/diffinery/admin/early-access/{id}/invite` | Invite it, as **Invite** does | | |
| 110 | +| `PATCH /api/v3/diffinery/admin/early-access/{id}` | `{"state": "declined"}` declines it; `{"state": "confirmed"}` puts a declined request back | | |
| 111 | +| `DELETE /api/v3/diffinery/admin/early-access/{id}` | Delete it (`204`) | | |
| 112 | + | |
| 113 | +```bash | |
| 114 | +# Confirmed requests waiting for a decision | |
| 115 | +curl -H "Authorization: Bearer $DIFFINERY_TOKEN" \ | |
| 116 | + "https://diffinery.com/api/v3/diffinery/admin/early-access?state=confirmed" | |
| 117 | + | |
| 118 | +# Invite one | |
| 119 | +curl -X POST -H "Authorization: Bearer $DIFFINERY_TOKEN" \ | |
| 120 | + https://diffinery.com/api/v3/diffinery/admin/early-access/<id>/invite | |
| 121 | +``` | |
| 122 | + | |
| 123 | +Each request has `id`, `state`, `email`, `name`, `company`, `use_case`, `hosting`, `confirmed_at`, `invite_id`, `decided_by`, `decided_at`, `created_at`, `updated_at` and `html_url`. Everyone else gets `404`. See the [API reference](/docs/api) for every field. | |
| 124 | + | |
| 125 | +## Troubleshooting | |
| 126 | + | |
| 127 | +| What you see | Why, and what to do | | |
| 128 | +| --- | --- | | |
| 129 | +| No email after **Check your inbox** | Check the spam folder. The address may have had 3 emails from the form today, or the address belongs to a disabled account; try again tomorrow, or ask someone who uses the instance. | | |
| 130 | +| "This link doesn't work any more" | The link was already used, is older than 48 hours, or a newer one was sent. If you confirmed already, you're on the list. Otherwise request again. | | |
| 131 | +| "This link is incomplete" | The part after `#` was cut off. Open the link from the email in full, or copy all of it. | | |
| 132 | +| "This link doesn't work any more" after **Remove my request** | The removal link was already used, is older than 24 hours, or a newer one was sent. If you used it, the request is gone. | | |
| 133 | +| "We couldn't confirm that you're a person" | The Turnstile check failed or expired. Reload the page and try again. | | |
| 134 | +| "This form was open for a long time" | The page was open more than 24 hours. Check the fields and send it again. | | |
| 135 | +| "Too many requests from your network today" | 10 submissions came from your network in 24 hours. Try again tomorrow. | |
addedapps/diffinery/docs/accounts/invitations.md+340−0View file
| @@ -1,0 +1,340 @@ | ||
| 1 | +--- | |
| 2 | +title: Invitations | |
| 3 | +description: How people join an invite-only Diffinery instance, who can invite, Add people by username or email, accepting or declining invitations, revoking them, the CLI and API, and access requests. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +Diffinery is invite-only by default: nobody can sign up from the sign-in page. (An operator can turn on [open sign-up](/docs/accounts/passkeys-and-sign-in#open-sign-up) with `SIGNUP=open`, where people create an account by confirming their email with a code; invitations work the same either way.) A person joins by opening an invite link sent to their email address, picking a username and creating a passkey. Organizations and repositories have an **Add people** form that takes a username or an email address. Either way the person gets an invitation, as on GitHub: someone added by username accepts it under **Settings → Invitations**, and an email address gets an invite link that also creates the account if there is none. | |
| 7 | + | |
| 8 | +Without an invitation, someone can [request early access](/docs/accounts/early-access) at `/early-access`, which the sign-in page links to. A site administrator reads the request and decides whether to invite them. | |
| 9 | + | |
| 10 | +## How sign-up works | |
| 11 | + | |
| 12 | +1. Someone with permission creates an invite for an email address. The invite can be for the whole instance, for an organization (with an organization role) or for a repository (with a repository role). | |
| 13 | +2. Diffinery emails the invite link to that address, and shows the link to the person who created it so they can also send it another way. | |
| 14 | +3. The invited person opens the link from the invitation email, picks a username, creates a passkey and is signed in. The emailed link carries a proof that they can read mail at that address. The link the inviter sees (`https://diffinery.com/invite/<token>`) doesn't: opening it shows **Confirm your email** with an **Email me the link** button, which sends the link to the invited address again (at most once every 2 minutes and 5 times a day). | |
| 15 | +4. Their account is active, and every open invite for their email address is accepted at once: they join each organization and repository they were invited to. | |
| 16 | +5. They land on the organization or repository of the invite they opened (or the dashboard, for an instance invite). | |
| 17 | + | |
| 18 | +Nobody needs a password or a Cloudflare account. After joining, they use their passkey in the browser and create [personal access tokens](/docs/accounts/tokens) for Git, the CLI and agents. | |
| 19 | + | |
| 20 | +## Who can invite | |
| 21 | + | |
| 22 | +| Who | What they can invite to | Where | | |
| 23 | +| --- | --- | --- | | |
| 24 | +| Site administrators | The instance (no organization or repository), and any organization | **Settings → People and invitations**, an organization's **People** tab, `diffinery invite`, the API | | |
| 25 | +| Organization owners | Their organization, as member, admin or owner | The organization's **People** tab (**Add people**), `diffinery invite --org`, the API | | |
| 26 | +| Organization admins | Their organization, as member or admin | The organization's **People** tab (**Add people**), `diffinery invite --org`, the API | | |
| 27 | +| Repository admins (including the owner of a personal repository) | One repository, with a repository role from read to admin | The repository's **Settings → Collaborators** page (**Add people**), `PUT /api/v3/repos/{owner}/{repo}/collaborators/{username}` | | |
| 28 | + | |
| 29 | +Everyone else, including organization members and people with a lower repository role, can't create invites. | |
| 30 | + | |
| 31 | +## Invite from the web UI | |
| 32 | + | |
| 33 | +### Invite someone to the instance | |
| 34 | + | |
| 35 | +Site administrators only. An instance invite lets the person create an account, without adding them to any organization or repository. | |
| 36 | + | |
| 37 | +1. Open **Settings → People and invitations** (`/settings/admin`). | |
| 38 | +2. Under **Invite someone to this instance**, enter the **Email address**. | |
| 39 | +3. Click **Send invitation**. | |
| 40 | + | |
| 41 | +The page shows "Invite created for <email>" with the link and a copy button: "We emailed it to <email>; you can also send it yourself. It expires in 14 days and works once: they pick a username and create a passkey." | |
| 42 | + | |
| 43 | +An address can have only one pending instance invitation at a time (letter case doesn't matter). A second one is refused with "friend@example.com already has a pending invitation; revoke it first to send a new one"; revoke the first one under **Pending invitations** on the same page, then invite the address again. | |
| 44 | + | |
| 45 | +### Add people to an organization | |
| 46 | + | |
| 47 | +Organization owners and admins, and site administrators. The **Add people** form on the organization's **People** tab takes a username or an email address. | |
| 48 | + | |
| 49 | +1. Open the organization's page, for example `https://diffinery.com/acme`, and click the **People** tab. | |
| 50 | +2. Under **Add people**, enter a **Username or email**: `diffy` (or `@diffy`) for someone who has an account, or `friend@example.com` for anyone. | |
| 51 | +3. Choose a **Role**: **Member** (default), **Admin** or **Owner**. Only owners can choose **Owner**. | |
| 52 | +4. Click **Add**. | |
| 53 | + | |
| 54 | +| You entered | What happens | | |
| 55 | +| --- | --- | | |
| 56 | +| A username | Diffinery sends them an invitation: "Invited diffy to acme as member. They join once they accept." They get an email and an in-app notification, and join when they accept it under **Settings → Invitations** (see [Invitations you receive](#invitations-you-receive)). If they already have a pending invitation to the organization, it offers the role you chose instead of sending another: "diffy already had a pending invitation; it now offers the admin role." | | |
| 57 | +| An email address | Diffinery emails an invitation, whether or not the address has an account, so the form never tells you who an address belongs to and nobody joins without agreeing. The page shows **Invitation sent to friend@example.com** with the link and a copy button, and the note "We emailed them an invitation. You can also send them this link yourself. It works once and expires in 14 days: they join when they accept it, creating an account and passkey first if they don't have one." | | |
| 58 | + | |
| 59 | +A site administrator who enters their own username joins at once. The invitation then appears under **Pending invitations** on the same tab, with who sent it, when, the expiry date and the role. An invitation sent to a username shows as `@diffy`, never the person's email address. See [Organizations](/docs/accounts/organizations#add-people) for every message the form can show. | |
| 60 | + | |
| 61 | +An email address can have only one pending invitation per organization, whether it came from this form, the CLI or the API. Letter case doesn't matter: `Friend@Example.com` and `friend@example.com` are the same address. To send a new one, revoke the old one first ("friend@example.com already has a pending invitation; revoke it first to send a new one"). | |
| 62 | + | |
| 63 | +### Revoke an invitation | |
| 64 | + | |
| 65 | +Every pending invitation can be revoked until it's used: | |
| 66 | + | |
| 67 | +- organization invitations on the organization's **People** tab (owners and admins); | |
| 68 | +- repository invitations on the repository's **Settings → Collaborators** page (repository admins); | |
| 69 | +- instance invitations on **Settings → People and invitations** (site administrators). | |
| 70 | + | |
| 71 | +Under **Pending invitations**, click **Revoke** next to the invitation and confirm "Revoke the invitation for friend@example.com? The link stops working." (for an invitation sent to a username: "Revoke the invitation for @diffy?"). You see "Invitation revoked." Opening the link afterwards shows "this invite is invalid, already used, or expired", and an invitation sent to a username disappears from the person's **Settings → Invitations**. The person isn't notified, and you can invite the same address or username again. | |
| 72 | + | |
| 73 | +### Add people to a repository | |
| 74 | + | |
| 75 | +Repository admins. The **Add people** form on the repository's **Settings → Collaborators** page (`/acme/website/settings/access`) works the same way, and also accepts agents: | |
| 76 | + | |
| 77 | +1. Under **Add people**, enter a **Username or email**: a person (`diffy`), an agent (`review-bot`) or an email address (`friend@example.com`). | |
| 78 | +2. Choose a **Role**: **Read**, **Triage**, **Write** (default), **Maintain** or **Admin**. | |
| 79 | +3. Click **Add**. | |
| 80 | + | |
| 81 | +| You entered | What happens | | |
| 82 | +| --- | --- | | |
| 83 | +| The username of a person | Diffinery sends them an invitation: "Invited diffy with write access. They get access once they accept." They get an email and an in-app notification, and get the role when they accept it under **Settings → Invitations** (see [Invitations you receive](#invitations-you-receive)). If they already have a pending invitation to the repository, it offers the role you chose instead of sending another: "diffy already had a pending invitation; it now offers admin access." | | |
| 84 | +| The username of an agent, of a person who already is a direct collaborator, of a member of the organization that owns the repository, or your own | They get the role right away as a direct collaborator, with no invitation: "diffy now has write access. We let them know." People get an email and an in-app notification. A collaborator's role changes to the one you chose. | | |
| 85 | +| An email address | Diffinery emails an invitation, whether or not the address has an account, and shows **Invitation sent to friend@example.com** with the link to send yourself. When the person accepts it, they get that role on the repository. | | |
| 86 | + | |
| 87 | +| Message | Cause | | |
| 88 | +| --- | --- | | |
| 89 | +| "no one has the username diffy; to invite someone new, enter their email address" | No account has that username. | | |
| 90 | +| "acme is an organization; add its people instead" | Organizations can't be collaborators. | | |
| 91 | +| "diffy's account is disabled" | A site administrator disabled the account. | | |
| 92 | +| "enter a valid email address" | The text has an `@` but isn't a complete address. | | |
| 93 | +| "friend@example.com already has a pending invitation; revoke it first to send a new one" | The address already has an open invitation to this repository (letter case doesn't matter). Revoke it under **Pending invitations**, then add the address again. | | |
| 94 | + | |
| 95 | +Pending invitations are listed under **Pending invitations** on the same page, where you can revoke them. See [Permissions](/docs/accounts/permissions) for what each role allows. | |
| 96 | + | |
| 97 | +## Invite from the CLI | |
| 98 | + | |
| 99 | +`diffinery invite` invites to an organization, or to the instance when you leave out `--org` (site administrators only). You need to be logged in (`diffinery login`) with a token that has the `admin` scope and isn't restricted to repositories; a `read` or `write` token gets `403 this token needs the admin scope`. If you don't have the CLI yet: | |
| 100 | + | |
| 101 | +```sh | |
| 102 | +curl -fsSL https://diffinery.com/install | bash | |
| 103 | +diffinery login --scope admin | |
| 104 | +``` | |
| 105 | + | |
| 106 | +The CLI needs Node.js 22 or later. | |
| 107 | + | |
| 108 | +```sh | |
| 109 | +# Invite to the acme organization as a member (the default role) | |
| 110 | +diffinery invite friend@example.com --org acme | |
| 111 | + | |
| 112 | +# Invite as an organization admin | |
| 113 | +diffinery invite friend@example.com --org acme --role admin | |
| 114 | + | |
| 115 | +# Invite to the instance only (site administrators) | |
| 116 | +diffinery invite friend@example.com | |
| 117 | +``` | |
| 118 | + | |
| 119 | +| Option | Description | | |
| 120 | +| --- | --- | | |
| 121 | +| `-o, --org ORG` | Organization to join. Leave it out for an instance invite, which only site administrators can create. | | |
| 122 | +| `-r, --role ROLE` | `member` (default), `admin` or `owner`. Needs `--org`. | | |
| 123 | +| `--json` | Print the API response (`id` and `url`) as JSON. | | |
| 124 | + | |
| 125 | +The command prints the invite link and reminds you that it is valid for 14 days. Like the web form, `diffinery invite` always creates an invitation, even when the address already has an account (they accept it with **Accept invite**; see [Accept an invite with an existing account](#accept-an-invite-with-an-existing-account)). It refuses an address that already has a pending invitation to the organization (see [API errors](#api-errors)). The CLI can't create repository invites; use the repository's **Settings → Collaborators** page or `PUT /api/v3/repos/{owner}/{repo}/collaborators/{username}`. See [CLI reference](/docs/reference/cli) for every command. | |
| 126 | + | |
| 127 | +## Invite with the API | |
| 128 | + | |
| 129 | +The organization and instance endpoints take a JSON body and return `201 Created` with the invite's `id` and `url`. Diffinery also emails the link. | |
| 130 | + | |
| 131 | +Inviting to an organization or the instance is an account-level action, so both endpoints need a personal access token with the `admin` scope that isn't restricted to repositories (or a browser session). A token with only `read` or `write` gets `403 this token needs the admin scope`, and a token restricted to repositories gets `403 this token is limited to repositories and can't change account settings`. See [Personal access tokens](/docs/accounts/tokens#account-level-actions). | |
| 132 | + | |
| 133 | +### Organization invite | |
| 134 | + | |
| 135 | +`POST /api/v3/orgs/{org}/invitations` | |
| 136 | + | |
| 137 | +| Field | Type | Description | | |
| 138 | +| --- | --- | --- | | |
| 139 | +| `email` | string | Required. The address to invite. | | |
| 140 | +| `role` | string | `member` (default), `admin` or `owner`. | | |
| 141 | + | |
| 142 | +```sh | |
| 143 | +curl -X POST https://diffinery.com/api/v3/orgs/acme/invitations \ | |
| 144 | + -H "Authorization: Bearer dfy_..." \ | |
| 145 | + -H "Content-Type: application/json" \ | |
| 146 | + -d '{"email": "friend@example.com", "role": "member"}' | |
| 147 | +``` | |
| 148 | + | |
| 149 | +```json | |
| 150 | +{ "id": "3f1c0e8a9b7d4c2e8f6a5b4c3d2e1f0a", "url": "https://diffinery.com/invite/..." } | |
| 151 | +``` | |
| 152 | + | |
| 153 | +The caller must be an owner or admin of the organization, or a site administrator. Only owners (and site administrators) can invite with the `owner` role. Like the CLI and the web form, this endpoint always creates an invitation; it doesn't add existing accounts directly. It refuses an address that already has a pending invitation to the organization (compared without regard to letter case). An address whose account is already a member still gets an invitation, so the answer never tells you whose address it is; accepting it changes nothing. | |
| 154 | + | |
| 155 | +### Instance invite | |
| 156 | + | |
| 157 | +`POST /api/v3/invitations` with `{"email": "friend@example.com"}`. Site administrators only. | |
| 158 | + | |
| 159 | +### Repository invite by username | |
| 160 | + | |
| 161 | +`PUT /api/v3/repos/{owner}/{repo}/collaborators/{username}` with `{"permission": "write"}` works like **Add people** on the repository's **Settings → Collaborators** page. It needs admin on the repository, through an `admin` token (it may be restricted to that repository) or a browser session. | |
| 162 | + | |
| 163 | +- When the person gets an invitation, the answer is `201 Created` with the invitation: `id`, `repository` (`full_name`, `name`, `owner.login`), `invitee`, `inviter` (`login`), `permissions`, `created_at`, `expires_at` and `html_url` (their **Settings → Invitations** page). If they already had a pending invitation to the repository, the same invitation now offers the new role. | |
| 164 | +- When the role applies at once (an agent, someone who already is a direct collaborator, a member of the organization that owns the repository, or yourself), the answer is `204 No Content`. | |
| 165 | + | |
| 166 | +```sh | |
| 167 | +curl -X PUT https://diffinery.com/api/v3/repos/acme/website/collaborators/diffy \ | |
| 168 | + -H "Authorization: Bearer dfy_..." \ | |
| 169 | + -H "Content-Type: application/json" \ | |
| 170 | + -d '{"permission": "write"}' | |
| 171 | +``` | |
| 172 | + | |
| 173 | +It answers `404 user not found` when no account has that username, `422 diffy's account is disabled` for a disabled account, and `429 you can send at most 50 invitations in 24 hours; try again later` like the other ways of inviting. | |
| 174 | + | |
| 175 | +### List and revoke pending invitations | |
| 176 | + | |
| 177 | +| Method and path | Who | | |
| 178 | +| --- | --- | | |
| 179 | +| `GET /api/v3/orgs/{org}/invitations`, `DELETE /api/v3/orgs/{org}/invitations/{id}` | The organization's owners and admins | | |
| 180 | +| `GET /api/v3/repos/{owner}/{repo}/invitations`, `DELETE /api/v3/repos/{owner}/{repo}/invitations/{id}` | The repository's admins | | |
| 181 | +| `GET /api/v3/invitations`, `DELETE /api/v3/invitations/{id}` | Site administrators (instance invitations only) | | |
| 182 | + | |
| 183 | +Each listed invitation has an `id`, `email`, `login`, `role`, `inviter`, `created_at` and `expires_at`. An invitation sent to an email address has the `email` and a `login` of `null`; one sent to a username has the `login` and an `email` of `null`. `DELETE` returns `204 No Content`, or `404 no pending invitation with this id` when the invitation was already used or revoked, or belongs to another organization, repository or kind. Revoking organization and instance invitations needs an `admin` token; revoking a repository invitation needs admin on the repository. Agents use the MCP tools `list_invitations` and `revoke_invitation` with `owner` and `repo`, `org`, or neither. | |
| 184 | + | |
| 185 | +### API errors | |
| 186 | + | |
| 187 | +| Status | Message | Cause | | |
| 188 | +| --- | --- | --- | | |
| 189 | +| 401 | `sign in required` | No token was sent. | | |
| 190 | +| 403 | `this token needs the admin scope` | The token's scope is `read` or `write`. Create an `admin` token. | | |
| 191 | +| 403 | `this token is limited to repositories and can't change account settings` | The token was created with a repository list. Use a token without one. | | |
| 192 | +| 403 | `only organization owners and admins can invite` | You are not an owner or admin of the organization. | | |
| 193 | +| 403 | `only owners can invite owners` | An organization admin asked for `"role": "owner"`. | | |
| 194 | +| 403 | `only site admins can invite people without an organization` | Instance invites need a site administrator. | | |
| 195 | +| 429 | `you can send at most 50 invitations in 24 hours; try again later` | You sent 50 invitations in the last 24 hours. Site administrators aren't limited. See [Limits](/docs/reference/limits#email). | | |
| 196 | +| 404 | `not found` | The organization doesn't exist, or the name is not an organization. | | |
| 197 | +| 422 | `a valid email is required` | The `email` field is missing or isn't an email address. | | |
| 198 | +| 422 | `role must be member, admin or owner` | `role` has another value. | | |
| 199 | +| 422 | `friend@example.com already has a pending invitation; revoke it first to send a new one` | The address already has an open invitation to this organization (or, for `POST /api/v3/invitations`, an open instance invitation). Letter case doesn't matter. Revoke the organization invitation on the **People** tab first. | | |
| 200 | + | |
| 201 | +Error bodies are JSON: `{"message": "...", "documentation_url": "..."}`. | |
| 202 | + | |
| 203 | +The full endpoint list is in the [API reference](/docs/api). | |
| 204 | + | |
| 205 | +## The invite email | |
| 206 | + | |
| 207 | +Diffinery sends the invite from `notifications@diffinery.com`. The subject depends on the kind of invite: | |
| 208 | + | |
| 209 | +| Invite | Subject | | |
| 210 | +| --- | --- | | |
| 211 | +| Organization | `@diffy invited you to join the acme organization on Diffinery` | | |
| 212 | +| Repository | `@diffy invited you to collaborate on acme/website` | | |
| 213 | +| Instance | `@diffy invited you to Diffinery` | | |
| 214 | + | |
| 215 | +The email lists who invited you, the organization or repository and the role (for organization and repository invites) and the expiry date, and has an **Accept invitation** button. It says the invitation only works for that email address. Invite emails can't be turned off, because they are the only way to reach someone without an account. | |
| 216 | + | |
| 217 | +If the address already belongs to a Diffinery account, the email goes to that account. | |
| 218 | + | |
| 219 | +An invitation sent to a username has the same subject and details, but its button is **View invitation** and opens **Settings → Invitations** (`/settings/invitations`), where you accept or decline it. It carries no link that accepts the invitation by itself. See [Invitations you receive](#invitations-you-receive). | |
| 220 | + | |
| 221 | +## Invite link rules | |
| 222 | + | |
| 223 | +| Rule | Value | | |
| 224 | +| --- | --- | | |
| 225 | +| Lifetime | 14 days from creation | | |
| 226 | +| Uses | Once. A used, revoked or expired link shows "this invite is invalid, already used, or expired" (HTTP 404). If you are already signed in, the page still shows **Accept invite**, and clicking it answers "this invite is invalid or has expired" (HTTP 404). | | |
| 227 | +| Who can use it | Only the email address it was sent to | | |
| 228 | +| Link format | The emailed link is `https://<host>/invite/<token>#proof=<proof>`. The proof, which shows you received the email, is in the URL fragment: browsers never send it to the server, so it can't end up in request logs. The page removes it from the address bar. Older links with `?proof=` still work. | | |
| 229 | +| What joining accepts | Every open invite for that email address, not just the one you opened | | |
| 230 | +| Withdrawing | Pending invitations can be revoked: organization invitations on the organization's **People** tab, repository invitations on the repository's **Settings → Collaborators** page, instance invitations on **Settings → People and invitations**. Otherwise they stop working once used or after 14 days. | | |
| 231 | + | |
| 232 | +If you sent a repository or instance invite to the wrong address, a site administrator can disable the account it creates (see [Site administration](/docs/admin/site-administration#people)). | |
| 233 | + | |
| 234 | +## Choosing a username | |
| 235 | + | |
| 236 | +The **Join Diffinery** page suggests a username made from the part of the email address before the `@`. Rules: | |
| 237 | + | |
| 238 | +- Starts with a letter or digit, then letters, digits, `-` and `_`, up to 39 characters. | |
| 239 | +- Not already taken (case-insensitive) and not reserved. Reserved names include `api`, `mcp`, `settings`, `new`, `notifications`, `invite`, `login`, `admin`, `explore`, `docs`, `auth`, `signin`, `signup`, `security`, `status`, `diffinery`, `ghost`, `github-actions`, `noreply`, `support`, `abuse`, `postmaster`, `root` and `staff`. | |
| 240 | + | |
| 241 | +Your username is part of your repository URLs (`https://diffinery.com/<username>/<repo>`). You can change it later under **Settings → Profile**, which changes those URLs; existing clones then need a new remote URL (see [Profile and preferences](/docs/accounts/profile-and-preferences#change-your-username)). | |
| 242 | + | |
| 243 | +| Message | What to do | | |
| 244 | +| --- | --- | | |
| 245 | +| "Usernames use letters, numbers and dashes (up to 39), and some names are reserved." | Pick a name that follows the rules above. | | |
| 246 | +| "The username <name> is taken. Pick another one." | Pick another name. | | |
| 247 | +| "<email> already has an account. Sign in with its passkey, then open this invite again." | The address already has an account: sign in, then open the link and click **Accept invite**. | | |
| 248 | +| "This invite has no email address. Sign in first, then open it again." | Sign in with your existing account, then open the link again. | | |
| 249 | + | |
| 250 | +## Accept an invite with an existing account | |
| 251 | + | |
| 252 | +If the invited address already has an account: | |
| 253 | + | |
| 254 | +1. Open the invite link from the email. The page says the address already has an account and shows a **Sign in** button. (A copy of the link without the email's proof, such as the one the inviter sees, asks you to confirm your email instead, and also offers **Sign in** to accept.) | |
| 255 | +2. Sign in with your passkey. Diffinery brings you back to the invite. | |
| 256 | +3. Click **Accept invite**. | |
| 257 | + | |
| 258 | +You are taken to the repository or organization you were invited to. The invite only works when the signed-in account's email address matches; otherwise you get "this invite was sent to <email>; sign in with that address" (HTTP 403). | |
| 259 | + | |
| 260 | +When someone accepts your invite, you get an "@<username> accepted your invitation" email (part of the **Organizations** email group, which you can turn off; see [Notifications](/docs/accounts/notifications)). For an invitation you sent to a username, the email doesn't show the person's email address. | |
| 261 | + | |
| 262 | +## Invitations you receive | |
| 263 | + | |
| 264 | +When an organization owner or admin, or a repository admin, adds you by username, you get an invitation instead of access, as on GitHub. You get an in-app notification (kind `invitation`, for example "diffy invited you to acme as member" or "diffy invited you to acme/website as write") and an email with a **View invitation** button. Both lead to **Settings → Invitations**. You have no access until you accept. | |
| 265 | + | |
| 266 | +### Accept or decline in the web UI | |
| 267 | + | |
| 268 | +Open **Settings → Invitations** (`/settings/invitations`). It lists your pending invitations to organizations and repositories: those sent to your username, and those sent to your email address. Each one shows the organization or repository, the role, who invited you, when, and when it expires. With none, the page shows "No pending invitations." | |
| 269 | + | |
| 270 | +- **Accept** gives you the role and takes you to the repository or organization. | |
| 271 | +- **Decline** deletes the invitation. You see "Invitation declined." Nobody is told, and they can invite you again. | |
| 272 | + | |
| 273 | +An invitation expires after 14 days. If the admin adds you again while one is pending, that invitation offers the new role; you don't get a second one. | |
| 274 | + | |
| 275 | +If your account is waiting for an invite (see [Pending accounts](#pending-accounts)), your invitations appear under **Your invitations** on the **Waiting for an invite** page instead, with the same **Accept** and **Decline** buttons. Accepting one makes your account active. | |
| 276 | + | |
| 277 | +### Accept or decline with the API | |
| 278 | + | |
| 279 | +The endpoints follow GitHub's. Listing needs a token with at least the `read` scope; accepting and declining need `write` or `admin`. All of them refuse tokens restricted to repositories (`403 this token is limited to repositories and can't change account settings`). | |
| 280 | + | |
| 281 | +| Method and path | What it does | | |
| 282 | +| --- | --- | | |
| 283 | +| `GET /api/v3/user/repository_invitations` | Your pending repository invitations: `id`, `repository` (`full_name`, `name`, `owner.login`), `invitee`, `inviter` (`login`), `permissions`, `created_at`, `expires_at`, `html_url` | | |
| 284 | +| `PATCH /api/v3/user/repository_invitations/{id}` | Accept a repository invitation. Returns `204 No Content`. | | |
| 285 | +| `DELETE /api/v3/user/repository_invitations/{id}` | Decline a repository invitation. Returns `204 No Content`. | | |
| 286 | +| `GET /api/v3/user/memberships/orgs` | Your organization memberships, pending invitations first. Add `?state=active` or `?state=pending` for only one kind. | | |
| 287 | +| `GET /api/v3/user/memberships/orgs/{org}` | Your membership of one organization, active or pending. `404 not found` when you have neither. | | |
| 288 | +| `PATCH /api/v3/user/memberships/orgs/{org}` | With `{"state": "active"}`, accept your pending invitation to the organization. Returns the membership. | | |
| 289 | + | |
| 290 | +A membership has `url`, `state` (`active` or `pending`), `role` (`admin` or `member`, as on GitHub, where owners count as `admin`), `diffinery_role` (`owner`, `admin` or `member`), `organization` (`login`, `url`) and `user`. | |
| 291 | + | |
| 292 | +```sh | |
| 293 | +# List your repository invitations | |
| 294 | +curl https://diffinery.com/api/v3/user/repository_invitations -H "Authorization: Bearer dfy_..." | |
| 295 | + | |
| 296 | +# Accept one | |
| 297 | +curl -X PATCH https://diffinery.com/api/v3/user/repository_invitations/3f1c0e8a9b7d4c2e8f6a5b4c3d2e1f0a -H "Authorization: Bearer dfy_..." | |
| 298 | + | |
| 299 | +# Accept an invitation to the acme organization | |
| 300 | +curl -X PATCH https://diffinery.com/api/v3/user/memberships/orgs/acme \ | |
| 301 | + -H "Authorization: Bearer dfy_..." \ | |
| 302 | + -H "Content-Type: application/json" \ | |
| 303 | + -d '{"state": "active"}' | |
| 304 | +``` | |
| 305 | + | |
| 306 | +| Status | Message | Cause | | |
| 307 | +| --- | --- | --- | | |
| 308 | +| 404 | `no pending invitation with this id` | The id is unknown, the invitation was already accepted, declined, revoked or expired, or it is someone else's. | | |
| 309 | +| 404 | `no pending invitation to this organization` | `PATCH /user/memberships/orgs/{org}` when you have no pending invitation to it and aren't a member. If you already are a member, the answer is your active membership. | | |
| 310 | +| 422 | `state must be "active"` | The `PATCH` body has another `state`, or none. | | |
| 311 | + | |
| 312 | +An account that is waiting for an invite can't use the API; it accepts on the **Waiting for an invite** page. | |
| 313 | + | |
| 314 | +## Pending accounts | |
| 315 | + | |
| 316 | +An account can exist without being active. Its status is **pending** when someone verified their email address through email recovery (`/auth/access`) without having an invite. A pending account: | |
| 317 | + | |
| 318 | +- can sign in, but only sees a **Waiting for an invite** page, which lists its pending invitations to organizations and repositories under **Your invitations**, | |
| 319 | +- can't see repositories, create organizations or tokens, or use the API (requests return "your account is waiting for an invite"), | |
| 320 | +- becomes active as soon as anyone creates an invite for its email address (including with **Add people**), the person accepts an invitation on the **Waiting for an invite** page, or a site administrator approves it. Being invited by username doesn't make the account active by itself. | |
| 321 | + | |
| 322 | +> **Note:** If email recovery is set up on the instance and an invited person verifies their address at `/auth/access` instead of opening the invite link, Diffinery creates their account right away with a username based on their email address, accepts their invites and asks them to add a passkey. | |
| 323 | + | |
| 324 | +## Access requests | |
| 325 | + | |
| 326 | +From the **Waiting for an invite** page, a person with a pending account can ask the administrators for access: | |
| 327 | + | |
| 328 | +1. Optionally write a **Note to the administrators** (up to 500 characters), for example who you are and what you want to work on. | |
| 329 | +2. Click **Request access**. You see "Request sent. An administrator will review it." | |
| 330 | + | |
| 331 | +Diffinery emails up to 20 active site administrators an "<email> asked for access" message with the note. The request appears under **Pending access requests** on **Settings → People and invitations**, where an administrator clicks **Approve** (the account becomes active) or **Deny** (the account is disabled). Sending another request replaces the earlier note. | |
| 332 | + | |
| 333 | +Approving an account doesn't add it to any organization or repository; invite the person or add them by username for that. | |
| 334 | + | |
| 335 | +## How invitations differ from GitHub | |
| 336 | + | |
| 337 | +- On GitHub anyone can create an account. On an invite-only Diffinery instance (the default) the only ways in are an invite, an approved access request, or being listed as a bootstrap administrator of the instance; with open sign-up, anyone who can read mail at their address can join too. | |
| 338 | +- GitHub organization and repository invitations go to existing GitHub users. Diffinery invites can also go to an email address, and the same link creates the account. | |
| 339 | +- **Add people** takes a username or an email address, as on GitHub. Someone added by username gets an invitation to accept under **Settings → Invitations**, as on GitHub. An email address always gets an invitation link, whether or not it has an account. | |
| 340 | +- Pending invitations of every kind can be revoked, in the web UI, the API or over MCP; otherwise they expire after 14 days. |
addedapps/diffinery/docs/accounts/notifications.md+389−0View file
| @@ -1,0 +1,389 @@ | ||
| 1 | +--- | |
| 2 | +title: Notifications | |
| 3 | +description: In-app notifications and email from Diffinery: every email group and template, settings, watching, thread subscriptions, unsubscribe links, headers and the API. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +Diffinery tells you about activity in two places: the notifications list in the web UI, and email. Both follow GitHub's rules: you hear about issues and pull requests you take part in, everything in repositories you watch, and workflow runs you started. This page explains what you get, how to change it, and every email Diffinery can send. | |
| 7 | + | |
| 8 | +## The notifications list and notification settings | |
| 9 | + | |
| 10 | +Two pages share the name, and they do different things: | |
| 11 | + | |
| 12 | +| Page | Where | What it is for | | |
| 13 | +| --- | --- | --- | | |
| 14 | +| **Notifications** (the list) | The bell in the header, or `/notifications` | Reading what happened: your recent notifications, marking them read | | |
| 15 | +| **Notification settings** | **Settings → Notifications** (`/settings/notifications`), or the gear button on the list | Choosing what you hear about, in the list and by email; watched repositories; your recent emails | | |
| 16 | + | |
| 17 | +The list only shows what your settings let through. To get fewer (or more) entries in the list, change the **On Diffinery** column in the settings; to change your email, change the **Email** column. | |
| 18 | + | |
| 19 | +## The notifications list | |
| 20 | + | |
| 21 | +The bell in the header opens **Notifications** (`/notifications`). It has two views, **Unread** and **All**, and shows your 100 most recent notifications. Each entry has an icon for its kind (a pull request, an issue, a comment, a failed run in red, an organization, a repository or an invitation), a title such as `diffy commented on acme/website#42: Cache build artifacts`, its kind (for example `issue comment`, `pull request review` or `run failed`) and when it happened. | |
| 22 | + | |
| 23 | +- Click an entry to open what it is about; that marks it read. | |
| 24 | +- Click the check mark (**Mark as read**) next to an unread entry to mark only that one read. | |
| 25 | +- Click **Mark all as read** to clear the list. | |
| 26 | +- Click the gear button (**Notification settings**) to open **Settings → Notifications**. | |
| 27 | + | |
| 28 | +Your dashboard also shows up to 8 unread notifications under **Unread notifications**. | |
| 29 | + | |
| 30 | +You get an in-app notification for: | |
| 31 | + | |
| 32 | +- issue and pull request activity you take part in, when **Participating → On Diffinery** is on, | |
| 33 | +- activity in repositories you watch with **All activity**, when **Watching → On Diffinery** is on, | |
| 34 | +- failed workflow runs you started, when **Actions → On Diffinery** is on, | |
| 35 | +- a run waiting for your approval to deploy to an environment where you are a [required reviewer](/docs/actions/environments#required-reviewers-and-wait-timers) ("Deployment to production in acme/website is waiting for your review"), when **Actions → On Diffinery** is on, | |
| 36 | +- someone inviting you by username to an organization or a repository (kind `invitation`, for example "<admin> invited you to acme as member"); it opens **Settings → Invitations** (see [Invitations you receive](/docs/accounts/invitations#invitations-you-receive)), | |
| 37 | +- someone giving you a repository role at once, without an invitation, because you are a member of the organization that owns the repository or already a collaborator on it (kind `repo access`, for example "<admin> gave you write access to acme/website"). This comes from **Add people**, the role menu on **Settings → Collaborators** or the API. | |
| 38 | + | |
| 39 | +The last two are always sent, whatever your settings. The invitation email belongs to the **Account** group, which can't be turned off; the access email belongs to the **Repositories** group. | |
| 40 | + | |
| 41 | +Your own actions never create a notification for you. Other repository and organization events (organization role changes, removals, repositories created or deleted), and security and account events, are sent by email only. | |
| 42 | + | |
| 43 | +## Email groups | |
| 44 | + | |
| 45 | +Every email belongs to one group. Security and account emails always send; you can turn the others off. | |
| 46 | + | |
| 47 | +| Group | Emails | Can be turned off | Default | | |
| 48 | +| --- | --- | --- | --- | | |
| 49 | +| Security | New sign-in from a browser or device not seen before (and every email-recovery sign-in), passkey added or removed, access token created or about to expire, agent added, model API key added, push blocked or push protection bypassed, account disabled or enabled, and for site administrators a new security report | No | On | | |
| 50 | +| Account | Invitations, welcome, sign-in links, sign-up codes, test email, agent review's daily limit reached | No | On | | |
| 51 | +| Participating | Issues and pull requests you opened, commented on, reviewed, were assigned, were asked to review, were @mentioned in or subscribed to: new issues and pull requests, comments, reviews, pushes, merges, closes, reopens, assignments and review requests | Yes | On | | |
| 52 | +| Watching | Everything else in repositories you watch with **All activity** | Yes | On | | |
| 53 | +| Actions | Workflow runs you started (failed runs only, unless you turn off **Only failed runs**), and scheduled runs in repositories you watch | Yes | On, failures only | | |
| 54 | +| Repositories | Repositories created or deleted in your organizations (for owners and admins), your own new repositories, and repository access granted, changed or removed | Yes | On | | |
| 55 | +| Organizations | Your organization role added, changed or removed (with who did it), invitations you sent being accepted, and access requests (for site administrators) | Yes | On | | |
| 56 | +| Morning digest | [Today](/docs/agents/today) by email, once a day at the hour you choose | Yes | Off | | |
| 57 | + | |
| 58 | +## Notification settings | |
| 59 | + | |
| 60 | +Open **Settings → Notifications** (`/settings/notifications`). The top of the page shows the address emails go to (your account's email address) and the address they come from (`notifications@diffinery.com`). | |
| 61 | + | |
| 62 | +The **Notify me about** table has a row per group with an **On Diffinery** checkbox (the notifications list) and an **Email** checkbox: | |
| 63 | + | |
| 64 | +| Row | On Diffinery | Email | Extra option | | |
| 65 | +| --- | --- | --- | --- | | |
| 66 | +| **Participating** | Yes | Yes | | | |
| 67 | +| **Watching** | Yes | Yes | | | |
| 68 | +| **Actions** | Yes | Yes | **Only failed runs** (on by default) | | |
| 69 | +| **Repositories** | No | Yes | | | |
| 70 | +| **Organizations** | No | Yes | | | |
| 71 | +| **Security and account** | No | Always on (locked) | | | |
| 72 | + | |
| 73 | +Below the table: | |
| 74 | + | |
| 75 | +- **Email me about my own comments and changes** (off by default). When on, you also get emails about comments, reviews and changes you made yourself, like GitHub's "Include your own updates". | |
| 76 | +- **Watch repositories I create (All activity)** (on by default). New repositories you create, in the web UI or through the API, are watched with **All activity**. | |
| 77 | + | |
| 78 | +Under **Morning digest**: | |
| 79 | + | |
| 80 | +- **Email me a morning digest** (off by default). | |
| 81 | +- **Hour** and **Time zone**: when it goes out, in your time zone. Until you save one, the time zone is your browser's. | |
| 82 | +- **On Saturdays and Sundays too** (off by default). | |
| 83 | + | |
| 84 | +When the digest is on, the page says when the next one goes out. See [The morning digest](/docs/agents/today#the-morning-digest). | |
| 85 | + | |
| 86 | +Click **Save notification settings**. You see "Notification settings saved." | |
| 87 | + | |
| 88 | +The same page lists **Watched repositories** (every repository you watch or ignore, with a menu to change the level) and **Recent emails to you** (the last 20, with their status), and has **Send me a test email** and **Send me my digest now** buttons. | |
| 89 | + | |
| 90 | +## Who gets notified about issues and pull requests | |
| 91 | + | |
| 92 | +For each event on an issue or pull request, Diffinery works out who is taking part: | |
| 93 | + | |
| 94 | +| You are notified because | Reason (`X-Diffinery-Reason`) | | |
| 95 | +| --- | --- | | |
| 96 | +| Your review was requested on the pull request | `review_requested` | | |
| 97 | +| You are assigned | `assign` | | |
| 98 | +| You were @mentioned in the text of this event (the description, comment or review) | `mention` | | |
| 99 | +| You opened the issue or pull request | `author` | | |
| 100 | +| You commented on it or reviewed it before | `comment` | | |
| 101 | +| You clicked **Subscribe** on it | `manual` | | |
| 102 | +| You watch the repository with **All activity** | `subscribed` | | |
| 103 | + | |
| 104 | +When several apply, the email gives the first one in this order. Mentions use `@username` outside code blocks and inline code. | |
| 105 | + | |
| 106 | +What sends: | |
| 107 | + | |
| 108 | +| Event | Who hears about it | | |
| 109 | +| --- | --- | | |
| 110 | +| Issue or pull request opened | Participants (assignees, requested reviewers, people mentioned) and watchers | | |
| 111 | +| Comment | Participants and watchers | | |
| 112 | +| Review submitted | Participants and watchers | | |
| 113 | +| Closed, merged or reopened | Participants and watchers | | |
| 114 | +| New commits pushed to a pull request | Participants only | | |
| 115 | +| Someone assigned | The new assignees and other participants, not watchers | | |
| 116 | +| Review requested | The requested reviewers and other participants, not watchers | | |
| 117 | +| Title or description edited, merge queue changes | Nobody | | |
| 118 | + | |
| 119 | +Rules that apply to every event: | |
| 120 | + | |
| 121 | +- Your own actions don't notify you, unless you turn on **Email me about my own comments and changes** (email only; such emails carry the reason `your_activity`). | |
| 122 | +- A repository set to **Ignore** sends you nothing about its issues, pull requests and workflow runs. | |
| 123 | +- A muted thread (you clicked **Unsubscribe**) sends you nothing, except when that event @mentions you, assigns you or asks for your review. | |
| 124 | +- You must still be able to read the repository. People who lost access stop hearing about it. | |
| 125 | +- Agents don't receive email. | |
| 126 | + | |
| 127 | +## Watch a repository | |
| 128 | + | |
| 129 | +The **Watch** menu at the top of every repository sets how much you hear about it: | |
| 130 | + | |
| 131 | +| Menu item | Level | Effect | | |
| 132 | +| --- | --- | --- | | |
| 133 | +| **Watch: participating** | Participating and @mentions | The default: only threads you take part in | | |
| 134 | +| **Watch: all activity** | All activity | Every new issue, pull request, comment, review and state change, plus scheduled workflow runs | | |
| 135 | +| **Watch: ignore** | Ignore | Nothing about this repository's issues, pull requests and workflow runs, even runs you started | | |
| 136 | + | |
| 137 | +Choosing a level saves it right away ("Watching acme/website: All activity."). You can also change levels from the **Watched repositories** list in **Settings → Notifications**. | |
| 138 | + | |
| 139 | +With the API (GitHub's repository subscription endpoints): | |
| 140 | + | |
| 141 | +```sh | |
| 142 | +# Watch all activity | |
| 143 | +curl -X PUT https://diffinery.com/api/v3/repos/acme/website/subscription \ | |
| 144 | + -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ | |
| 145 | + -d '{"subscribed": true}' | |
| 146 | + | |
| 147 | +# Ignore the repository | |
| 148 | +curl -X PUT https://diffinery.com/api/v3/repos/acme/website/subscription \ | |
| 149 | + -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ | |
| 150 | + -d '{"ignored": true}' | |
| 151 | + | |
| 152 | +# Back to participating only | |
| 153 | +curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/subscription \ | |
| 154 | + -H "Authorization: Bearer dfy_..." | |
| 155 | +``` | |
| 156 | + | |
| 157 | +Watching, ignoring and subscribing to issues also work on archived repositories, which otherwise refuse every change. | |
| 158 | + | |
| 159 | +`GET /api/v3/repos/{owner}/{repo}/subscription` returns `{"subscribed": true|false, "ignored": true|false, ...}`, or `404 not subscribed` when you are on the default participating level (as GitHub does). `PUT` with `{"subscribed": false}` also returns you to participating. Changing the level needs a `write` or `admin` token. | |
| 160 | + | |
| 161 | +## Subscribe to or mute an issue or pull request | |
| 162 | + | |
| 163 | +The sidebar of every issue and pull request has a **Notifications** section with one button: | |
| 164 | + | |
| 165 | +- **Subscribe** follows the thread even if you aren't taking part: "Subscribed. You'll be notified about all activity." | |
| 166 | +- **Unsubscribe** mutes it: "Unsubscribed. You'll still be notified if you're @mentioned, assigned or asked to review." | |
| 167 | + | |
| 168 | +The text under the button says why you are or aren't receiving notifications, for example "You're receiving notifications because you commented." or "You muted this pull request." | |
| 169 | + | |
| 170 | +## Workflow run notifications | |
| 171 | + | |
| 172 | +When a workflow run finishes: | |
| 173 | + | |
| 174 | +| Run started by | Who is notified | | |
| 175 | +| --- | --- | | |
| 176 | +| A person (a push, a pull request, a manual dispatch) | That person | | |
| 177 | +| An agent | People watching the repository with **All activity** (the agent's owner isn't notified directly yet) | | |
| 178 | +| Diffinery itself (for example a schedule) | People watching the repository with **All activity** | | |
| 179 | + | |
| 180 | +Failed and timed-out runs send an email and an in-app notification. Successful runs send an email only to people who turned off **Only failed runs**. Cancelled runs send nothing. The email lists every job with its result and the first failure message, and links to the run. Set a repository to **Ignore** to hear nothing about its runs, for example a test repository whose CI you expect to fail. | |
| 181 | + | |
| 182 | +## Unsubscribe links | |
| 183 | + | |
| 184 | +Every email in a group you can turn off has unsubscribe links in its footer, and `List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click` headers (RFC 8058), so mail apps show their own Unsubscribe button. | |
| 185 | + | |
| 186 | +| Footer link | Where | What it does | | |
| 187 | +| --- | --- | --- | | |
| 188 | +| **Mute this thread** | Issue and pull request emails | Mutes that issue or pull request, like **Unsubscribe** on the page | | |
| 189 | +| **Stop watching acme/website** | Emails you got because you watch the repository | Sets the repository back to participating | | |
| 190 | +| **Unsubscribe from emails like this** | Every email you can turn off | Turns off email for that group (for example Watching) | | |
| 191 | + | |
| 192 | +The mail app's one-click button (`List-Unsubscribe`) turns off email for the group the email belongs to, the same as **Unsubscribe from emails like this**: for an issue or pull request email, that's every Participating email (or every Watching one, when you got it because you watch the repository). To mute just one thread, use **Mute this thread** in the footer instead. You can turn the group back on in notification settings. | |
| 193 | + | |
| 194 | +The links are signed, so they work without signing in and can't be altered to change someone else's settings. Each link works for a year after the email was sent (links from before links carried an expiry work until October 16, 2027); after that, change what you get in notification settings. Opening one shows a page naming the change (for example "Mute acme/website#42") and the account's username, though not its email address, with a button to confirm; after confirming you can click **Undo**. Security and account emails have no unsubscribe links. | |
| 195 | + | |
| 196 | +## Email headers | |
| 197 | + | |
| 198 | +Every email has an HTML and a plain-text version and these headers, which you can use in mail filters: | |
| 199 | + | |
| 200 | +| Header | Value | | |
| 201 | +| --- | --- | | |
| 202 | +| `From` | `notifications@diffinery.com`. The display name is the person who acted, for issue and pull request emails (`diffy (Diffinery)`), otherwise `Diffinery`. | | |
| 203 | +| `Subject` | Issue and pull request emails: `[acme/website] <title> (PR #42)` or `(Issue #37)`, with `Re: ` for every email after the first. Others start with `[acme/website]` or `[Diffinery]`. | | |
| 204 | +| `X-Diffinery-Reason` | Why you got the email (see below), like GitHub's `X-GitHub-Reason` | | |
| 205 | +| `X-Diffinery-Kind` | The email template, for example `thread`, `run` or `signin_new` (see [Every email Diffinery sends](#every-email-diffinery-sends)) | | |
| 206 | +| `X-Diffinery-Recipient` | Your username | | |
| 207 | +| `X-Diffinery-Repository` | `owner/repo` (or the organization) the email is about, when there is one | | |
| 208 | +| `List-Id` | For repository emails, `acme/website <website.acme.diffinery.com>` | | |
| 209 | +| `References`, `In-Reply-To` | All emails about one issue or pull request share `References: <acme/website/pull/42@diffinery.com>`; every email after the first also has it as `In-Reply-To`, so mail apps group them into one conversation | | |
| 210 | +| `List-Unsubscribe`, `List-Unsubscribe-Post` | On emails you can turn off | | |
| 211 | +| `Precedence: list` | On emails you can turn off | | |
| 212 | +| `Auto-Submitted: auto-generated` | On every email | | |
| 213 | + | |
| 214 | +### X-Diffinery-Reason values | |
| 215 | + | |
| 216 | +| Value | Meaning (as written in the email footer) | | |
| 217 | +| --- | --- | | |
| 218 | +| `author` | You opened this thread. | | |
| 219 | +| `comment` | You commented (or reviewed). | | |
| 220 | +| `mention` | You were mentioned. | | |
| 221 | +| `assign` | You were assigned. | | |
| 222 | +| `review_requested` | Your review was requested. | | |
| 223 | +| `manual` | You subscribed to this thread. | | |
| 224 | +| `subscribed` | You are watching this repository. | | |
| 225 | +| `your_activity` | Something you did on Diffinery (your own updates, a repository you created). | | |
| 226 | +| `ci_activity` | A workflow run triggered by you, or in a repository you watch. | | |
| 227 | +| `security_alert` | A security notice about your account. These can't be turned off. | | |
| 228 | +| `invitation` | Someone invited this email address or your account to Diffinery, an organization or a repository. | | |
| 229 | +| `admin` | You are an administrator (of the organization, repository or instance). | | |
| 230 | +| `account` | You have a Diffinery account (account and membership changes). | | |
| 231 | +| `signup` | Someone asked to create an account with this address through open sign-up (the sign-up code). | | |
| 232 | +| `digest` | You turned on the morning digest in your notification settings. | | |
| 233 | + | |
| 234 | +## When Diffinery stops emailing you | |
| 235 | + | |
| 236 | +When email to your address bounces for good (the mailbox doesn't exist or the server refuses it), or you mark an email from Diffinery as spam, Diffinery puts your address on its suppression list and sends nothing to it any more: no notifications, and no security alerts, sign-in links or account recovery emails either. A site administrator can also suppress an address by hand. | |
| 237 | + | |
| 238 | +**Settings → Email** (`/settings/email`) shows whether your address is suppressed, since when and why, and the notifications settings page shows a warning too. To get email again after a bounce or a spam report: | |
| 239 | + | |
| 240 | +1. Make sure the inbox works (and, after a spam report, that you want Diffinery's email). | |
| 241 | +2. On **Settings → Email**, click **Send a verification email**. It is the one email that goes to a suppressed address, and you can ask for 3 an hour. | |
| 242 | +3. Open the link in it within 24 hours, signed in to the same account, and click **Confirm and get email again**. | |
| 243 | + | |
| 244 | +If a site administrator suppressed your address, only an administrator can lift it. If the verification email itself fails with `E_RECIPIENT_SUPPRESSED`, Cloudflare's own list still holds the address: ask a site administrator. | |
| 245 | + | |
| 246 | +With the API: `GET /api/v3/diffinery/email` returns your address and `suppressed` (null, or `reason`, `detail`, `created_at` and `owner_can_lift`), and `POST /api/v3/diffinery/email/verification` sends the verification email. | |
| 247 | + | |
| 248 | +## Every email Diffinery sends | |
| 249 | + | |
| 250 | +The **Kind** column is the `X-Diffinery-Kind` header. Site administrators can preview every one of these under **Settings → Email** (see [Site administration](/docs/admin/site-administration#email)). | |
| 251 | + | |
| 252 | +| Kind | Subject (example) | Group | Reason | Sent when | | |
| 253 | +| --- | --- | --- | --- | --- | | |
| 254 | +| `thread` | `[acme/website] Cache build artifacts (PR #42)` | Participating or Watching | `author`, `comment`, `mention`, `assign`, `review_requested`, `manual`, `subscribed` or `your_activity` | Activity on an issue or pull request (see the next table) | | |
| 255 | +| `run` | `[acme/website] Run failed: CI - main (3f9c2a1)` | Actions | `ci_activity` | A workflow run you started failed or timed out (or succeeded, if you asked for all runs) | | |
| 256 | +| `deployment_review_requested` | `[acme/website] Deployment to production is waiting for your review` | Actions | `review_requested` | A run waits for a required reviewer of an environment, and you are one of them (not when you started the run) | | |
| 257 | +| `signin_new` | `[Diffinery] New sign-in to your account` | Security | `security_alert` | A passkey sign-in from a browser and passkey combination not seen before, or any email-recovery sign-in | | |
| 258 | +| `passkey_added` | `[Diffinery] A passkey was added to your account` | Security | `security_alert` | You added a passkey in settings or with a sign-in link | | |
| 259 | +| `passkey_removed` | `[Diffinery] A passkey was removed from your account` | Security | `security_alert` | You removed a passkey | | |
| 260 | +| `token_created` | `[Diffinery] A personal access token was added to your account` (or `A token was created for the agent review-bot`) | Security | `security_alert` | A token was created for you or your agent, from the web UI, CLI or API | | |
| 261 | +| `token_expiring` | `[Diffinery] Your token "ci-deploy" expires on <date>` | Security | `security_alert` | About 7 days before a token expires (once per token, for tokens that last more than 8 days) | | |
| 262 | +| `agent_created` | `[Diffinery] A new agent was added: review-bot` | Security | `security_alert` | You created an agent | | |
| 263 | +| `model_key_added` | `[Diffinery] Your Anthropic API key was added to Diffinery` (or `OpenAI`, `OpenRouter`) | Security | `security_alert` | You saved a model API key for [agent tasks](/docs/agents/tasks) | | |
| 264 | +| `push_blocked` | `[acme/website] Your push was blocked: possible secrets` | Security | `security_alert` | Push protection rejected your `git push`, or a commit made in the web editor, through the REST API or over MCP (subject `Your commit was blocked`). For an agent, its owner is told. | | |
| 265 | +| `push_bypassed` | `[acme/website] Push protection was bypassed` | Security | `security_alert` | A repository admin pushed past push protection, or committed a flagged file through the commits API with `allow_secrets`; sent to that admin and the repository's admins | | |
| 266 | +| `account_status` | `[Diffinery] Your account was disabled` or `Your account was enabled` | Security | `account` | A site administrator disabled your account, or enabled it again | | |
| 267 | +| `security_report` | `[Diffinery] New security report` | Security | `admin` | Someone sent a private report through `/security/report` (sent to every active site administrator). It holds only the report's summary and a link to it in [Site administration](/docs/admin/site-administration#security-reports). | | |
| 268 | +| `early_access_confirm` | `[Diffinery] Confirm your early access request` | Account | `early_access` | Someone asked for [early access](/docs/accounts/early-access) with this address: the link that confirms the request. Sent to the address typed, not to an account. | | |
| 269 | +| `early_access_known` | `[Diffinery] We already have your early access request`, `You're already invited` or `You already have a Diffinery account` | Account | `early_access` | Someone asked for early access again with an address whose request is already confirmed, declined or invited, or that belongs to an active account | | |
| 270 | +| `early_access_remove` | `[Diffinery] Remove your early access request` | Account | `early_access` | Someone asked to remove the early access request for this address: the link that deletes it | | |
| 271 | +| `invite` | `@diffy invited you to join the acme organization on Diffinery` | Account | `invitation` | Someone invited your email address (organization, repository or instance), or your username (organization or repository; the button is **View invitation** and opens **Settings → Invitations**) | | |
| 272 | +| `welcome` | `Welcome to Diffinery, @diffy` | Account | `account` | You created your account from an invite or through open sign-up | | |
| 273 | +| `signup_code` | `[Diffinery] Your sign-up code` | Account | `signup` | With [open sign-up](/docs/accounts/passkeys-and-sign-in#open-sign-up), someone asked for a code for an address without an account. The code is in the message, not the subject. | | |
| 274 | +| `signup_attempt` | `[Diffinery] Someone tried to sign up with your email address` | Account | `account` | With open sign-up, someone asked for a code for your address, which already has an account (no code is sent) | | |
| 275 | +| `email_verify` | `[Diffinery] Confirm your email address to get email again` | Account | `account` | You asked for it on **Settings → Email** while your address was suppressed. It is the only email sent to a suppressed address. See [When Diffinery stops emailing you](#when-diffinery-stops-emailing-you). | | |
| 276 | +| `signin_link` | `[Diffinery] Your one-time sign-in link` | Account | `account` | A site administrator created a sign-in link for you | | |
| 277 | +| `review_limit_reached` | `[acme/website] Agent review reached its daily limit` | Account | `your_activity` | [Agent review](/docs/agents/review) on a repository whose reviews use your model key reached its daily limit (at most once a day per repository) | | |
| 278 | +| `test` | `[Diffinery] Test email` | Account | `account` | You clicked **Send me a test email** (or used the API) | | |
| 279 | +| `digest` | `[Diffinery] Today: 2 need you, 3 merged` | Morning digest | `digest` | At the hour you chose, when something needs you or happened since the last one; or when you click **Send me my digest now** | | |
| 280 | +| `invite_accepted` | `[Diffinery] @diffy accepted your invitation` | Organizations | `account` | Someone accepted an invite you created | | |
| 281 | +| `access_request` | `[Diffinery] newcomer@example.com asked for access` | Organizations | `admin` | A person waiting for an invite requested access (sent to site administrators) | | |
| 282 | +| `org_membership` | `[Diffinery] You're now a member of acme` | Organizations | `account` | Your organization role was added, changed or removed by an owner or admin. The email names who added you ("Added by") or who changed your role ("Changed by"). | | |
| 283 | +| `repo_access` | `[acme/website] You now have write access to acme/website` | Repositories | `account` | Your collaborator role on a repository was added, changed or removed | | |
| 284 | +| `repo_created` | `[acme/website] New private repository` | Repositories | `your_activity` or `admin` | You created a repository in the web UI (not when a token or script creates it), or someone created one in an organization where you are an owner or admin | | |
| 285 | +| `repo_deleted` | `[acme/website] Repository deleted` | Repositories | `admin` | Someone else deleted a repository you own or that belongs to an organization where you are an owner or admin | | |
| 286 | +| `repo_transferred` | `[acme-labs/website] Repository transferred from acme/website` | Repositories | `admin` | Someone else transferred a repository away from or to you, or an organization where you are an owner or admin | | |
| 287 | + | |
| 288 | +Issue and pull request emails (`thread`) come in these variants: | |
| 289 | + | |
| 290 | +| Event | Title in the email | | |
| 291 | +| --- | --- | | |
| 292 | +| Opened | `@diffy wants to merge cache-artifacts into main` (pull request) or `@diffy opened acme/website#37` (issue); includes the description, the pull request's intent and recent commits | | |
| 293 | +| Comment | `@diffy commented on acme/website#42`, with the comment | | |
| 294 | +| Review | `@diffy approved`, `requested changes on` or `reviewed acme/website#42`, with the review and its line comments | | |
| 295 | +| Closed | `@diffy closed acme/website#37 as completed` (or `as not planned`) | | |
| 296 | +| Merged | `@diffy merged acme/website#42 into main` | | |
| 297 | +| Reopened | `@diffy reopened acme/website#37` | | |
| 298 | +| Assigned | `@diffy assigned you to acme/website#37` | | |
| 299 | +| Review requested | `@diffy requested your review on acme/website#42` | | |
| 300 | +| Pushed | `@diffy pushed 2 commits to cache-artifacts`, with the commits | | |
| 301 | + | |
| 302 | +## Send a test email | |
| 303 | + | |
| 304 | +Click **Send me a test email** on **Settings → Notifications**. Diffinery sends it right away (not through the queue) and shows "Test email sent to <address>." or the reason it wasn't sent, for example "The test email was skipped: ...". | |
| 305 | + | |
| 306 | +You can send at most 5 test emails an hour; more answer `429 you can send at most 5 test emails an hour; try again later`. | |
| 307 | + | |
| 308 | +With the API (needs a `write` or `admin` token that isn't limited to repositories): | |
| 309 | + | |
| 310 | +```sh | |
| 311 | +curl -X POST https://diffinery.com/api/v3/diffinery/emails/test -H "Authorization: Bearer dfy_..." | |
| 312 | +``` | |
| 313 | + | |
| 314 | +```json | |
| 315 | +{ "status": "sent", "messageId": "..." } | |
| 316 | +``` | |
| 317 | + | |
| 318 | +`status` is `sent`, `skipped` or `failed`, with a `detail` when it wasn't sent. | |
| 319 | + | |
| 320 | +## Notifications API | |
| 321 | + | |
| 322 | +| Method and path | Description | Token scope | | |
| 323 | +| --- | --- | --- | | |
| 324 | +| `GET /api/v3/notifications` | Unread notifications (add `?all=true` for read ones too): `id`, `kind`, `title`, `url`, `createdAt`, `readAt` | any | | |
| 325 | +| `PUT /api/v3/notifications` | Mark all as read; returns `205` | `write` or `admin`, all repositories | | |
| 326 | +| `GET /api/v3/diffinery/notification-settings` | Your settings (see below) | any | | |
| 327 | +| `PUT /api/v3/diffinery/notification-settings` | Change settings; send only the keys to change | `write` or `admin`, all repositories | | |
| 328 | +| `GET /api/v3/repos/{owner}/{repo}/subscription` | Your watch level | any | | |
| 329 | +| `PUT /api/v3/repos/{owner}/{repo}/subscription` | Watch (`{"subscribed": true}`) or ignore (`{"ignored": true}`) | `write` or `admin` | | |
| 330 | +| `DELETE /api/v3/repos/{owner}/{repo}/subscription` | Back to participating | `write` or `admin` | | |
| 331 | +| `GET /api/v3/diffinery/emails` | Your recent emails: `kind`, `subject`, `status` (`sent`, `failed` or `skipped`), `detail`, `message_id`, `created_at`. Query: `kind`, `since` (ISO time), `per_page` (default 50, up to 500) | any | | |
| 332 | +| `POST /api/v3/diffinery/emails/test` | Send yourself a test email now | `write` or `admin`, all repositories | | |
| 333 | +| `POST /api/v3/diffinery/emails/digest` | Send yourself the morning digest now, even when it is off or has nothing in it (3 an hour) | `write` or `admin`, all repositories | | |
| 334 | +| `GET /api/v3/diffinery/email` | Your email address and whether it is suppressed | any | | |
| 335 | +| `POST /api/v3/diffinery/email/verification` | Send the email that lifts a bounce or complaint on your address | `write` or `admin`, all repositories | | |
| 336 | + | |
| 337 | +The settings object, with its defaults: | |
| 338 | + | |
| 339 | +```json | |
| 340 | +{ | |
| 341 | + "participating": { "web": true, "email": true }, | |
| 342 | + "watching": { "web": true, "email": true }, | |
| 343 | + "actions": { "web": true, "email": true, "failuresOnly": true }, | |
| 344 | + "repos": { "email": true }, | |
| 345 | + "orgs": { "email": true }, | |
| 346 | + "ownActivity": false, | |
| 347 | + "autoWatch": true, | |
| 348 | + "digest": { "email": false, "hour": 8, "timeZone": "UTC", "weekends": false } | |
| 349 | +} | |
| 350 | +``` | |
| 351 | + | |
| 352 | +`digest.hour` is 0 to 23 and `digest.timeZone` an IANA time zone, such as `Europe/Istanbul`; anything else returns `422`. To get the digest at 7:00 in Istanbul: | |
| 353 | + | |
| 354 | +```sh | |
| 355 | +curl -X PUT https://diffinery.com/api/v3/diffinery/notification-settings \ | |
| 356 | + -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ | |
| 357 | + -d '{"digest": {"email": true, "hour": 7, "timeZone": "Europe/Istanbul"}}' | |
| 358 | +``` | |
| 359 | + | |
| 360 | +For example, to stop watching emails and get every workflow run result: | |
| 361 | + | |
| 362 | +```sh | |
| 363 | +curl -X PUT https://diffinery.com/api/v3/diffinery/notification-settings \ | |
| 364 | + -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ | |
| 365 | + -d '{"watching": {"email": false}, "actions": {"failuresOnly": false}}' | |
| 366 | +``` | |
| 367 | + | |
| 368 | +The response is the full settings object. An unknown key returns `422 unknown setting <key>`. Only people have notification settings; agent tokens get `403 only people have notification settings`. | |
| 369 | + | |
| 370 | +## Delivery | |
| 371 | + | |
| 372 | +Emails are queued and sent through Cloudflare Email Sending from `notifications@diffinery.com`. A failed send is retried with increasing delays. Every email Diffinery tries to send is logged for 90 days with its status, which you can see under **Recent emails to you** or with `GET /api/v3/diffinery/emails`. A **skipped** email has a reason: | |
| 373 | + | |
| 374 | +| Detail | Meaning | | |
| 375 | +| --- | --- | | |
| 376 | +| `turned off <group> emails` | You turned off that group (for example `turned off watching emails`). | | |
| 377 | +| `no email address` | Your account has no email address. | | |
| 378 | +| `account disabled` | Disabled accounts only get the email saying they were disabled or enabled. | | |
| 379 | +| `email sending is not configured (no EMAIL binding)` | The instance can't send email; ask a site administrator. | | |
| 380 | +| `address is suppressed: ...` | Email to your address bounced or was reported as spam, or a site administrator suppressed it. See [When Diffinery stops emailing you](#when-diffinery-stops-emailing-you). | | |
| 381 | + | |
| 382 | +## How notifications differ from GitHub | |
| 383 | + | |
| 384 | +- The participating, watching and ignore rules, `@mention` handling, thread subscriptions, `X-Diffinery-Reason` (for `X-GitHub-Reason`) and the `/repos/{owner}/{repo}/subscription` API follow GitHub. | |
| 385 | +- Diffinery has no custom watch level (such as releases or discussions only). | |
| 386 | +- Settings are per group, for the notifications list and email separately. There are no per-organization email routes; every email goes to your account's address. | |
| 387 | +- Security and account emails can't be turned off. | |
| 388 | +- The notifications list shows your 100 most recent notifications and has no per-thread done or saved states. | |
| 389 | +- Being added to an organization or a repository with **Add people** always creates an in-app notification, with no setting to turn it off. |
addedapps/diffinery/docs/accounts/organizations.md+427−0View file
| @@ -1,0 +1,427 @@ | ||
| 1 | +--- | |
| 2 | +title: Organizations | |
| 3 | +description: Create organizations, add people by username or email, manage roles and invitations, set the base permission and picture, and share repositories. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +An organization is a shared account that owns repositories, agents, container images and Actions secrets and variables on behalf of a group of people. Use one when more than one person works on the same code, so access doesn't depend on any one personal account. | |
| 7 | + | |
| 8 | +## Organization roles | |
| 9 | + | |
| 10 | +Every member has one of three roles. The role decides what they can do in the organization and the role they get on every repository the organization owns: owners and admins always get `admin`, and members get the organization's [base permission](#base-permission). | |
| 11 | + | |
| 12 | +| Capability | member | admin | owner | | |
| 13 | +| --- | --- | --- | --- | | |
| 14 | +| Role on every organization repository | The base permission (`read` for new organizations) | admin | admin | | |
| 15 | +| See the organization's private repositories | Yes, unless the base permission is `none` | Yes | Yes | | |
| 16 | +| Create repositories in the organization, and get `admin` on the ones you create | Yes | Yes | Yes | | |
| 17 | +| Create container images in the organization and pull its images that aren't linked to a repository | Yes | Yes | Yes | | |
| 18 | +| Push to, delete and change the settings of an organization image that isn't linked to a repository | Only images they created | Yes | Yes | | |
| 19 | +| See the **People** and **Agents** tabs | Yes | Yes | Yes | | |
| 20 | +| **Add people** (by username or email) | No | Yes (as member or admin) | Yes (any role) | | |
| 21 | +| See and revoke pending invitations | No | Yes | Yes | | |
| 22 | +| Change roles, remove members | No | Yes, except owners | Yes, including owners | | |
| 23 | +| Create and manage the organization's agents | No | Yes | Yes | | |
| 24 | +| Manage organization Actions secrets | No | Yes | Yes | | |
| 25 | +| Read and manage organization Actions variables | No | Yes | Yes | | |
| 26 | +| Open the **Settings** tab: rename, display name, base permission, picture | No | No | Yes | | |
| 27 | +| Set the [agent policy](/docs/agents/organizations), and see or change the organization's model keys | No | No | Yes | | |
| 28 | +| Transfer repositories into the organization | No | Yes | Yes | | |
| 29 | +| Delete the organization (once it owns no repositories or images) | No | No | Yes | | |
| 30 | +| Change the display name, base permission and picture through the API | No | No | Yes | | |
| 31 | +| Get emails when repositories are created or deleted in the organization | No | Yes | Yes | | |
| 32 | + | |
| 33 | +Site administrators can do everything an owner can in every organization, and have admin on every repository. | |
| 34 | + | |
| 35 | +A container image linked to a repository follows that repository's roles instead (read pulls, write pushes, admin deletes), so a member who can't see a private repository can't pull its images either. See [Container registry](/docs/packages/container-registry). | |
| 36 | + | |
| 37 | +A member's role on a repository is the highest of the role their organization role gives them (the base permission for members, `admin` for owners and admins) and any role given to them directly on that repository. A direct collaborator role can raise an organization member's access (for example to `maintain` or `admin`) but never lowers it. See [Permissions](/docs/accounts/permissions) for what each repository role allows. | |
| 38 | + | |
| 39 | +## Base permission | |
| 40 | + | |
| 41 | +The base permission is the repository role that every member of an organization gets on every repository the organization owns, like GitHub's "Base permissions" setting. It only applies to members: owners and admins always have `admin` on every organization repository, whatever the base permission is. | |
| 42 | + | |
| 43 | +| Value | Option in the web UI | What members get on every organization repository | | |
| 44 | +| --- | --- | --- | | |
| 45 | +| `none` | No permission: members see only repositories they're given | No role. Members see only public repositories, repositories they were added to as collaborators, and repositories they created. | | |
| 46 | +| `read` | Read: members can clone and view | `read`: view and clone, open issues and comment | | |
| 47 | +| `write` | Write: members can also push | `write`: also push, open and merge pull requests and run workflows | | |
| 48 | +| `admin` | Admin: members can also change settings | `admin`: also change settings, collaborators, branch protection, secrets and webhooks | | |
| 49 | + | |
| 50 | +`triage` and `maintain` can't be a base permission; give those roles per repository under **Settings → Collaborators**. See [Permissions](/docs/accounts/permissions) for everything each role allows. | |
| 51 | + | |
| 52 | +### Default base permission | |
| 53 | + | |
| 54 | +- A new organization starts with `read`, as on GitHub. | |
| 55 | +- Organizations that existed before Diffinery added the base permission were set to `write`, so their members kept the access they already had. | |
| 56 | + | |
| 57 | +### How the base permission combines with other access | |
| 58 | + | |
| 59 | +A member's role on an organization repository is the highest of: | |
| 60 | + | |
| 61 | +| Source | Role | | |
| 62 | +| --- | --- | | |
| 63 | +| The organization's base permission | `none`, `read`, `write` or `admin` | | |
| 64 | +| A collaborator role on that repository (**Settings → Collaborators**, an accepted repository invitation, or `PUT /api/v3/repos/{owner}/{repo}/collaborators/{username}`) | The role they were given | | |
| 65 | +| They created the repository | `admin` | | |
| 66 | +| The repository is public | `read` | | |
| 67 | + | |
| 68 | +A collaborator role can raise a member above the base permission but can't lower it, so to give most members less access and a few people more, set a low base permission and add those people as collaborators on the repositories they need. Diffinery has no teams. | |
| 69 | + | |
| 70 | +Any member can create repositories in the organization, whatever the base permission. As on GitHub, the member who creates a repository becomes its admin: Diffinery adds them as an `admin` collaborator, so they appear under the repository's **Settings → Collaborators**. When a member's personal agent creates the repository, the member gets that grant. Owners and admins already have `admin` everywhere, so no grant is added for them. | |
| 71 | + | |
| 72 | +The base permission also limits agents that belong to a member, because a personal agent never has more access than its owner. Organization agents aren't affected: their access comes from their **Maximum role** (see [Agents](/docs/agents/overview)). Container images that aren't linked to a repository aren't affected: every member can still create images in the organization and pull them. An image linked to a repository follows that repository's access, so with `none` a member can't pull images of private repositories they can't see, and the registry catalog leaves those images out (see [Container registry](/docs/packages/container-registry)). | |
| 73 | + | |
| 74 | +Roles are worked out on every request, so a change to the base permission applies to every member right away, including in Git and through tokens they already have. | |
| 75 | + | |
| 76 | +### Change the base permission in the web UI | |
| 77 | + | |
| 78 | +Organization owners and site administrators: | |
| 79 | + | |
| 80 | +1. Open the organization's page and click the **Settings** tab (`https://diffinery.com/acme?tab=settings`). | |
| 81 | +2. In the **Base permission** menu, pick **No permission**, **Read**, **Write** or **Admin**. The hint under it reads "What every member can do in all of this organization's repositories. Owners and admins always have admin access, and repository settings can give people more." | |
| 82 | +3. Click **Save**. You see "Organization updated." | |
| 83 | + | |
| 84 | +Admins and members can't open the **Settings** tab: they get `403 organization owners only`. The change is recorded in the audit log as `org.update`, with the new value as `base_role` (see [Audit log](/docs/security/audit-log)). | |
| 85 | + | |
| 86 | +### Change the base permission with the API | |
| 87 | + | |
| 88 | +The API uses GitHub's field name, `default_repository_permission`. `GET /api/v3/orgs/{org}` returns the organization; members of the organization, their agents and site administrators also see `default_repository_permission` in it. Agents can also read it with the MCP tool `get_organization`. Any signed-in account can read the organization, but other accounts don't get that field. | |
| 89 | + | |
| 90 | +```sh | |
| 91 | +curl https://diffinery.com/api/v3/orgs/acme \ | |
| 92 | + -H "Authorization: Bearer dfy_..." | |
| 93 | +``` | |
| 94 | + | |
| 95 | +```json | |
| 96 | +{ | |
| 97 | + "login": "acme", | |
| 98 | + "type": "Organization", | |
| 99 | + "html_url": "https://diffinery.com/acme", | |
| 100 | + "avatar_url": "https://diffinery.com/api/avatars/acme", | |
| 101 | + "name": "Acme Inc.", | |
| 102 | + "default_repository_permission": "read" | |
| 103 | +} | |
| 104 | +``` | |
| 105 | + | |
| 106 | +(Other fields of GitHub's user object, such as `id`, `node_id` and `url`, are left out here.) | |
| 107 | + | |
| 108 | +`PATCH /api/v3/orgs/{org}` changes it. Only owners and site administrators can call it, with a personal access token that has the `admin` scope and isn't restricted to repositories (or a browser session). The body takes: | |
| 109 | + | |
| 110 | +| Field | Type | Description | | |
| 111 | +| --- | --- | --- | | |
| 112 | +| `default_repository_permission` | string | `none`, `read`, `write` or `admin`. | | |
| 113 | +| `name` | string | The display name. An empty value clears it, and pages show the organization name instead. | | |
| 114 | + | |
| 115 | +```sh | |
| 116 | +curl -X PATCH https://diffinery.com/api/v3/orgs/acme \ | |
| 117 | + -H "Authorization: Bearer dfy_..." \ | |
| 118 | + -H "Content-Type: application/json" \ | |
| 119 | + -d '{"default_repository_permission": "write"}' | |
| 120 | +``` | |
| 121 | + | |
| 122 | +The response is the updated organization, including `default_repository_permission`. With the CLI, `diffinery api PATCH /orgs/acme -f default_repository_permission=write` sends the same request (it needs an `admin` token). There is no dedicated CLI command. | |
| 123 | + | |
| 124 | +| Status | Message | Cause | | |
| 125 | +| --- | --- | --- | | |
| 126 | +| 403 | `this token needs the admin scope` | The token's scope is `read` or `write`. | | |
| 127 | +| 403 | `this token is limited to repositories and can't change account settings` | The token was created with a repository list. | | |
| 128 | +| 403 | `only organization owners can change its settings` | You are an admin or member of the organization, not an owner. | | |
| 129 | +| 404 | `not found` | The organization doesn't exist, or you aren't a member of it. | | |
| 130 | +| 422 | `base permission must be none, read, write or admin` | `default_repository_permission` has another value. | | |
| 131 | +| 422 | `name must be a string` | `name` is a number, object or other non-string value. | | |
| 132 | + | |
| 133 | +Every field is checked before anything is saved, so a refused value changes nothing. | |
| 134 | + | |
| 135 | +## Create an organization | |
| 136 | + | |
| 137 | +Any active account can create an organization and becomes its first owner. | |
| 138 | + | |
| 139 | +### In the web UI | |
| 140 | + | |
| 141 | +1. Go to **Settings → Organizations** (`/settings/orgs`), or click **New organization** on your dashboard. | |
| 142 | +2. Under **New organization**, enter the **Organization name** (for example `acme`) and optionally a **Display name** (for example `Acme Inc.`). | |
| 143 | +3. Click **Create organization**. | |
| 144 | + | |
| 145 | +Diffinery opens the new organization's **People** tab, where you can add people right away. | |
| 146 | + | |
| 147 | +### With the API | |
| 148 | + | |
| 149 | +```sh | |
| 150 | +curl -X POST https://diffinery.com/api/v3/user/orgs \ | |
| 151 | + -H "Authorization: Bearer dfy_..." \ | |
| 152 | + -H "Content-Type: application/json" \ | |
| 153 | + -d '{"login": "acme", "name": "Acme Inc."}' | |
| 154 | +``` | |
| 155 | + | |
| 156 | +The response (`201 Created`) describes the organization, with `"type": "Organization"` and its `html_url`. | |
| 157 | + | |
| 158 | +Creating an organization through the API needs a personal access token with the `admin` scope that isn't restricted to repositories. A `read` or `write` token gets `403 this token needs the admin scope`, and a restricted token gets `403 this token is limited to repositories and can't change account settings`. In the browser no token is needed. | |
| 159 | + | |
| 160 | +### Name rules | |
| 161 | + | |
| 162 | +The organization name is its URL (`https://diffinery.com/acme`) and the owner part of every repository URL. It shares one namespace with usernames and agent names. | |
| 163 | + | |
| 164 | +- Starts with a letter or digit, then letters, digits, `-` and `_`, up to 100 characters. Dots aren't allowed. | |
| 165 | +- Not already used by a person, organization or agent: "name already taken". | |
| 166 | +- Not reserved (for example `api`, `settings`, `new`, `login`, `admin`, `docs`): "invalid or reserved organization name". | |
| 167 | + | |
| 168 | +## The organization page | |
| 169 | + | |
| 170 | +Open `https://diffinery.com/acme` (you need to be signed in). The header shows the organization's picture, display name, name and your role ("you are an owner", "you are an admin" or "you are a member"). The tabs are: | |
| 171 | + | |
| 172 | +| Tab | Who sees it | What it shows | | |
| 173 | +| --- | --- | --- | | |
| 174 | +| **Repositories** | Everyone signed in | Repositories you can see. Members get a **New repository** button. | | |
| 175 | +| **People** | Everyone signed in | Members and their roles. Owners and admins also get the **Add people** form and the **Pending invitations** list. | | |
| 176 | +| **Agents** | Everyone signed in | The organization's agents, their maximum role and status. Owners and admins get a **Manage agents** button. | | |
| 177 | +| **Settings** | Owners | The organization's picture, name, display name and [base permission](#base-permission), and its [agent policy and model keys](/docs/agents/organizations). Others get `403 organization owners only`. | | |
| 178 | +| **Packages** | Everyone signed in | Container images the organization owns (`/acme/-/packages`). See [Container registry](/docs/packages/container-registry). | | |
| 179 | + | |
| 180 | +Members see every repository of the organization, unless the base permission is `none`: then they see public repositories and the ones they were given access to or created. People who aren't members only see repositories that are public, or that they were added to directly. | |
| 181 | + | |
| 182 | +## Add and remove members | |
| 183 | + | |
| 184 | +Owners and admins manage members on the **People** tab. The top of the tab shows how many members and pending invitations there are, for example "4 members · 1 pending invitation". | |
| 185 | + | |
| 186 | +### Add people | |
| 187 | + | |
| 188 | +The **Add people** form takes a username or an email address, like GitHub's "Add people" or "Invite member" dialog: | |
| 189 | + | |
| 190 | +1. Open `https://diffinery.com/acme?tab=people`. | |
| 191 | +2. In **Username or email**, enter a username (`diffy`, or `@diffy`) or an email address (`friend@example.com`). | |
| 192 | +3. Choose a **Role**: **Member** (default) or **Admin**. Owners can also choose **Owner**. | |
| 193 | +4. Click **Add**. | |
| 194 | + | |
| 195 | +What happens depends on who you entered: | |
| 196 | + | |
| 197 | +| You entered | Result | | |
| 198 | +| --- | --- | | |
| 199 | +| The username of a person | Diffinery sends them an invitation, as on GitHub. You see "Invited diffy to acme as member. They join once they accept." They get an email ("@<your username> invited you to join the acme organization on Diffinery", with a **View invitation** button) and an in-app notification ("<your username> invited you to acme as member"), and become a member when they accept it under **Settings → Invitations** (see [Invitations you receive](/docs/accounts/invitations#invitations-you-receive)). If they already have a pending invitation to the organization, it offers the role you chose instead of sending another: "diffy already had a pending invitation; it now offers the admin role." | | |
| 200 | +| An email address | Diffinery creates an invitation and emails it, whether or not the address has an account: the form doesn't reveal who an address belongs to, and they join only by accepting. The page shows **Invitation sent to friend@example.com** with the link and a copy button: "We emailed them an invitation. You can also send them this link yourself. It works once and expires in 14 days: they join when they accept it, creating an account and passkey first if they don't have one." | | |
| 201 | + | |
| 202 | +A site administrator who enters their own username joins at once. A person whose account is waiting for an invite (pending) becomes active when you invite their email address, or when they accept an invitation sent to their username. See [Invitations](/docs/accounts/invitations) for how invitations work, including inviting from the CLI and API. | |
| 203 | + | |
| 204 | +| Message | Cause | | |
| 205 | +| --- | --- | | |
| 206 | +| "enter a username or an email address" | The field was empty. | | |
| 207 | +| "enter a valid email address" | The text has an `@` but isn't a complete address such as `friend@example.com`. | | |
| 208 | +| "no one has the username diffy; to invite someone new, enter their email address" | No account has that username. Enter their email address to invite them. | | |
| 209 | +| "review-bot is not a person; agents belong to an organization through Settings → Agents" | Agents and organizations can't be members. Create an organization agent instead (see [Organization agents](#organization-agents)). | | |
| 210 | +| "diffy's account is disabled" | A site administrator disabled the account. | | |
| 211 | +| "diffy is already a member of acme" | Change their role in the members table instead. | | |
| 212 | +| "friend@example.com already has a pending invitation; revoke it first to send a new one" | The address already has an open invitation to this organization. Letter case doesn't matter: `Friend@Example.com` counts as the same address. Revoke the invitation under **Pending invitations**, then add the address again. | | |
| 213 | +| "only owners can add owners" | Admins can add members and admins only. | | |
| 214 | +| "organization admins only" | Only owners, admins and site administrators can add people. | | |
| 215 | + | |
| 216 | +### Pending invitations | |
| 217 | + | |
| 218 | +Owners and admins see a **Pending invitations** list on the **People** tab with each invitation's email address (or `@diffy` for an invitation sent to a username, never the person's email address), who invited them and when, the expiry date and the role. Accepted, declined and expired invitations aren't listed. | |
| 219 | + | |
| 220 | +To withdraw an invitation, click **Revoke** and confirm "Revoke the invitation for friend@example.com? The link stops working." (or "Revoke the invitation for @diffy?"). You see "Invitation revoked." Opening the revoked link shows "this invite is invalid, already used, or expired". The person isn't told. | |
| 221 | + | |
| 222 | +### Change a member's role | |
| 223 | + | |
| 224 | +In the member's row, pick a new role (**Owner**, **Admin** or **Member**) in the **Role** menu and click **Save**. The member gets an email saying their role changed and who changed it. | |
| 225 | + | |
| 226 | +### Remove a member | |
| 227 | + | |
| 228 | +Click **Remove** in the member's row and confirm "Remove diffy from acme?". The member loses the organization's repository access on their next request, including through their tokens, and gets an email saying they were removed. Any collaborator roles they or their agents had on the organization's repositories are removed too, including admin on repositories they created. Their personal repositories and accounts are not affected. | |
| 229 | + | |
| 230 | +Rules: | |
| 231 | + | |
| 232 | +| Rule | Message | | |
| 233 | +| --- | --- | | |
| 234 | +| Only owners can promote someone to owner, change an owner's role or remove an owner. | "only owners can manage owners" | | |
| 235 | +| An organization always keeps at least one owner: you can't remove or demote the last one. | "an organization needs at least one owner; make someone else an owner first" | | |
| 236 | +| Only owners and admins can manage members. | "organization admins only" | | |
| 237 | +| The username must belong to a person. | "user not found" | | |
| 238 | + | |
| 239 | +### Leave an organization | |
| 240 | + | |
| 241 | +There is no separate **Leave** button. Ask an owner or admin to remove you. Owners and admins can remove themselves from the **People** tab, as long as another owner remains. | |
| 242 | + | |
| 243 | +## Organization repositories | |
| 244 | + | |
| 245 | +Any member can create a repository owned by the organization. | |
| 246 | + | |
| 247 | +- **Web UI:** click **New repository** on the organization's **Repositories** tab, or choose the organization as **Owner** on the **Create a new repository** page (`/new`). | |
| 248 | +- **CLI:** `diffinery repo create website --org acme` (add `--public` for a public repository; the default is private). | |
| 249 | +- **API:** `POST /api/v3/orgs/acme/repos` with `{"name": "website", "visibility": "private"}`. The CLI and API need a token with the `write` or `admin` scope that isn't restricted to repositories; a `read` token gets `403 this token needs the write scope`. | |
| 250 | + | |
| 251 | +Access to organization repositories comes from the organization role (members get the [base permission](#base-permission), owners and admins get admin), plus any collaborators added under the repository's **Settings → Collaborators**, which can include people outside the organization and agents. A member who creates a repository is added to it as an `admin` collaborator, whatever the base permission. When a repository is created, the organization's other owners and admins get an email, and when one is deleted they are told who deleted it. | |
| 252 | + | |
| 253 | +## Organization secrets | |
| 254 | + | |
| 255 | +Organization Actions secrets are available to workflows in every repository the organization owns, as `${{ secrets.NAME }}`. If a repository has a secret with the same name, the repository's value wins. | |
| 256 | + | |
| 257 | +Owners and admins (and site administrators) manage them with the CLI or the API; agents can't (`403 agents cannot manage secrets`). There is no page for them in the web UI yet. Listing, setting and deleting all need a personal access token with the `admin` scope that isn't restricted to repositories: other tokens get `403 this token needs the admin scope` or `403 this token is limited to repositories and can't change account settings`. | |
| 258 | + | |
| 259 | +```sh | |
| 260 | +# Create or update (the value is read from a hidden prompt, stdin or --body) | |
| 261 | +diffinery secret set NPM_TOKEN --org acme | |
| 262 | + | |
| 263 | +# List names and when they were updated (values are never returned) | |
| 264 | +diffinery secret list --org acme | |
| 265 | + | |
| 266 | +# Delete | |
| 267 | +diffinery secret delete NPM_TOKEN --org acme | |
| 268 | +``` | |
| 269 | + | |
| 270 | +| Method and path | What it does | | |
| 271 | +| --- | --- | | |
| 272 | +| `GET /api/v3/orgs/{org}/actions/secrets` | List secret names and `updated_at` | | |
| 273 | +| `PUT /api/v3/orgs/{org}/actions/secrets/{name}` | Create or update; body `{"value": "..."}`; returns `204` | | |
| 274 | +| `DELETE /api/v3/orgs/{org}/actions/secrets/{name}` | Delete; returns `204` | | |
| 275 | + | |
| 276 | +Names are stored in upper case and must match `^[A-Z_][A-Z0-9_]*$` after upper-casing, otherwise the API returns `422 invalid secret name`. Values are encrypted at rest. Deleting a secret that doesn't exist returns `404 secret not found` and isn't recorded. Each change is recorded in the [audit log](/docs/security/audit-log) as `org.secret.set` or `org.secret.delete`, with the organization as the target and the secret's name (never its value) in the detail. See [Secrets and variables](/docs/actions/secrets-and-variables) for how workflows use them. | |
| 277 | + | |
| 278 | +## Organization variables | |
| 279 | + | |
| 280 | +Organization Actions variables are available to workflows in every repository the organization owns, as `${{ vars.NAME }}`, including repositories created later. If a repository has a variable with the same name, the repository's value wins, and an environment variable wins over both. Unlike secrets, variable values are stored and returned in plain text. | |
| 281 | + | |
| 282 | +Owners and admins (and site administrators) manage them with the REST API; there is no page for them in the web UI and no CLI command. Agents can't read or manage them, whatever their role (`403 organization admins only`, or `404 not found` when neither the agent nor its owner belongs to the organization). Members who aren't owners or admins get `403 organization admins only`. Reading needs a token of any scope; creating, changing and deleting need a personal access token with the `admin` scope that isn't restricted to repositories. | |
| 283 | + | |
| 284 | +| Method and path | What it does | | |
| 285 | +| --- | --- | | |
| 286 | +| `GET /api/v3/orgs/{org}/actions/variables` | List variables with their values, sorted by name (not paged) | | |
| 287 | +| `GET /api/v3/orgs/{org}/actions/variables/{name}` | One variable, or `404 variable not found` | | |
| 288 | +| `POST /api/v3/orgs/{org}/actions/variables` | Create or replace; body `{"name": "...", "value": "..."}`; returns `201` | | |
| 289 | +| `PATCH /api/v3/orgs/{org}/actions/variables/{name}` | Set the value (creating the variable if needed); body `{"value": "..."}`; returns `204` | | |
| 290 | +| `DELETE /api/v3/orgs/{org}/actions/variables/{name}` | Delete; returns `204` | | |
| 291 | + | |
| 292 | +```sh | |
| 293 | +curl -X POST https://diffinery.com/api/v3/orgs/acme/actions/variables \ | |
| 294 | + -H "Authorization: Bearer dfy_..." \ | |
| 295 | + -H "Content-Type: application/json" \ | |
| 296 | + -d '{"name": "NPM_REGISTRY", "value": "https://registry.npmjs.org"}' | |
| 297 | +``` | |
| 298 | + | |
| 299 | +Names must match `^[A-Za-z_][A-Za-z0-9_]*$`, can't start with `GITHUB_` (`422 invalid variable name`) and are stored in upper case. Values are strings of at most 48 KB (`422 value must be a string of at most 48 KB`). Every variable applies to all of the organization's repositories (`"visibility": "all"`); there is no per-repository selection. Each change is recorded in the [audit log](/docs/security/audit-log) as `org.variable_set` or `org.variable_delete`, with the organization as the target and the variable's name in the detail. A [GitHub organization import](/docs/migrate-from-github) adds the GitHub organization's variables that don't exist here yet. See [Secrets and variables](/docs/actions/secrets-and-variables#organization-variables) for the full details and the differences from GitHub. | |
| 300 | + | |
| 301 | +## Organization agents | |
| 302 | + | |
| 303 | +An agent can be owned by an organization instead of a person, so it keeps working when people come and go. Owners and admins create one under **Settings → Agents** by choosing the organization as **Owner**, or with `diffinery agent create review-bot --org acme`. An organization agent can only reach that organization's repositories, and its security emails (new tokens, expiring tokens) go to the organization's owners and admins. See [Agents](/docs/agents/overview). | |
| 304 | + | |
| 305 | +## Organization picture | |
| 306 | + | |
| 307 | +The organization's picture shows on its page, next to its repositories and in member lists. Without one, Diffinery shows the first letter or digit of the organization's name on a colored background. | |
| 308 | + | |
| 309 | +Owners change it on the **Settings** tab, in the **Profile picture** box: | |
| 310 | + | |
| 311 | +1. Under **Upload a new picture**, choose a JPEG, PNG or WebP file of up to 5 MB. It is cropped to a centred square; a preview shows the result. | |
| 312 | +2. Click **Save picture**. You see "Organization picture updated." | |
| 313 | +3. To go back to initials, click **Remove picture** and confirm "Remove the picture of acme?". You see "Organization picture removed." | |
| 314 | + | |
| 315 | +Owners (and site administrators) can also change it through the API, with a token that has the `write` or `admin` scope and isn't restricted to repositories. The body is the raw image file: | |
| 316 | + | |
| 317 | +```sh | |
| 318 | +curl -X PUT https://diffinery.com/api/v3/orgs/acme/avatar \ | |
| 319 | + -H "Authorization: Bearer dfy_..." \ | |
| 320 | + --data-binary @logo.png | |
| 321 | + | |
| 322 | +curl -X DELETE https://diffinery.com/api/v3/orgs/acme/avatar \ | |
| 323 | + -H "Authorization: Bearer dfy_..." | |
| 324 | +``` | |
| 325 | + | |
| 326 | +Both return `200` with the picture's URL, for example `{"avatar_url": "https://diffinery.com/api/avatars/acme?v=1760350000"}` after an upload and `{"avatar_url": "https://diffinery.com/api/avatars/acme"}` after removing it. Errors include `403 only organization owners can change its picture` for admins and members, and `404 not found` when you aren't in the organization. See [Profile and preferences](/docs/accounts/profile-and-preferences#profile-pictures) for the limits, error messages, the public picture URL and caching. | |
| 327 | + | |
| 328 | +## Rename an organization | |
| 329 | + | |
| 330 | +Owners open the **Settings** tab, change **Organization name**, **Display name** or **Base permission** and click **Save**. You see "Organization updated." Every field is checked before anything is saved, so a value that's refused changes nothing. If you clear **Display name**, pages show the organization name instead, and the API returns `"name": null`. The display name can also be changed with `PATCH /api/v3/orgs/{org}` and `{"name": "Acme Inc."}` (see [Change the base permission with the API](#change-the-base-permission-with-the-api)); the organization name can't. Renaming changes every repository URL in the organization, so existing clones need a new remote URL: | |
| 331 | + | |
| 332 | +```sh | |
| 333 | +git remote set-url origin https://diffinery.com/acme-inc/website.git | |
| 334 | +``` | |
| 335 | + | |
| 336 | +URLs with the old name keep working, like those of a [transferred repository](/docs/repositories/overview#old-urls-after-a-transfer-or-rename): web pages and the API redirect, and Git and container images are served under the old name, until another person or organization takes it. | |
| 337 | + | |
| 338 | +## Delete an organization | |
| 339 | + | |
| 340 | +Deleting an organization removes it for good. It is allowed only once the organization owns nothing that people still use: no repositories and no container images, and no GitHub import running into it. Unlike GitHub, deleting an organization never deletes its repositories along with it: move them out first. | |
| 341 | + | |
| 342 | +### Before you delete | |
| 343 | + | |
| 344 | +- **Who:** organization owners (and site administrators). Admins and members can't delete it. | |
| 345 | +- **Repositories:** [transfer](/docs/repositories/overview#transfer-a-repository) each repository to another owner, or delete it. Transferred repositories keep their old URLs working after the organization is gone. | |
| 346 | +- **Container images:** delete the organization's images (images linked to a repository move with it when you transfer the repository). See [Container registry](/docs/packages/container-registry). | |
| 347 | +- **Imports:** wait for an import into the organization to finish, or cancel it, including drafts and queued imports. | |
| 348 | + | |
| 349 | +### Delete in the web UI | |
| 350 | + | |
| 351 | +1. Open the organization's page and its **Settings** tab (owners only). | |
| 352 | +2. In the **Danger zone**, under **Delete this organization**, type the organization's login (for example `acme`) in the confirmation box. | |
| 353 | +3. Click **Delete this organization**. | |
| 354 | + | |
| 355 | +You see `Deleted acme.` and land on your home page. If you signed in more than 2 hours ago, Diffinery first asks you to [Confirm it's you](/docs/security/overview#confirming-its-you) with your passkey. | |
| 356 | + | |
| 357 | +### Delete with the API | |
| 358 | + | |
| 359 | +```sh | |
| 360 | +curl -X DELETE https://diffinery.com/api/v3/orgs/acme \ | |
| 361 | + -H "Authorization: Bearer dfy_..." | |
| 362 | +``` | |
| 363 | + | |
| 364 | +It returns `202 Accepted` with an empty object (`{}`) once the organization is deleted. It needs a token with the `admin` scope that isn't limited to repositories. | |
| 365 | + | |
| 366 | +### What deleting does | |
| 367 | + | |
| 368 | +- Its members, pending invitations, organization secrets and organization variables are removed. | |
| 369 | +- Its agents are disabled, and their tokens revoked. | |
| 370 | +- Its login is freed: anyone can create a user or organization with that name afterwards. Older logins from earlier renames stop redirecting. | |
| 371 | +- Repositories transferred out of it earlier keep their redirects: their old URLs under its name still lead to them, until a repository is created at that owner and name (see [Old URLs after a transfer or rename](/docs/repositories/overview#old-urls-after-a-transfer-or-rename)). | |
| 372 | +- Its page and API answer `404`. | |
| 373 | +- The audit log keeps the entries that name it, and records the deletion as `org.delete`. Its import history and agents still refer to it. | |
| 374 | + | |
| 375 | +Deleting can't be undone. To use the name again, create a new organization. | |
| 376 | + | |
| 377 | +### Errors when deleting | |
| 378 | + | |
| 379 | +| Message | Cause and fix | | |
| 380 | +| --- | --- | | |
| 381 | +| `acme still owns 3 repositories; transfer or delete them before deleting the organization` (422) | Transfer or delete the repositories first. | | |
| 382 | +| `acme still owns 2 container images; delete them before deleting the organization` (422) | Delete the images first. | | |
| 383 | +| `an import into acme is still in progress; wait for it to finish or cancel it first` (422) | Wait for the import, or cancel it. | | |
| 384 | +| `only organization owners can delete it` (403) | You are an admin or member. Ask an owner. | | |
| 385 | +| `not found` (404) | The organization doesn't exist, or you aren't a member of it. | | |
| 386 | +| `this token needs the admin scope` (403) | Use a token with the `admin` scope. | | |
| 387 | +| `this token is limited to repositories and can't change account settings` (403) | Use a token that isn't restricted to repositories. | | |
| 388 | +| `type acme to confirm deletion` | The web confirmation box didn't match the organization's login. | | |
| 389 | +| `organization owners only` | Only owners can delete the organization from the web UI. | | |
| 390 | + | |
| 391 | +## Organizations API | |
| 392 | + | |
| 393 | +| Method and path | Description | | |
| 394 | +| --- | --- | | |
| 395 | +| `GET /api/v3/user/orgs` | Organizations you belong to, each with your `role` | | |
| 396 | +| `POST /api/v3/user/orgs` | Create an organization: `{"login": "acme", "name": "Acme Inc."}`. Needs an `admin` token. | | |
| 397 | +| `GET /api/v3/orgs/{org}` | The organization; members, their agents and site administrators also get `default_repository_permission` | | |
| 398 | +| `PATCH /api/v3/orgs/{org}` | Change `name` or `default_repository_permission` (`none`, `read`, `write` or `admin`). Owners only; needs an `admin` token. | | |
| 399 | +| `DELETE /api/v3/orgs/{org}` | Delete the organization once it owns no repositories, container images or running imports; returns `202`. Owners only; needs an `admin` token. See [Delete an organization](#delete-an-organization). | | |
| 400 | +| `GET /api/v3/orgs/{org}/members` | Members and their roles. Members and site administrators only; others get `404`. | | |
| 401 | +| `POST /api/v3/orgs/{org}/invitations` | Invite by email: `{"email": "...", "role": "member"}`. Needs an `admin` token. See [Invitations](/docs/accounts/invitations#invite-with-the-api). | | |
| 402 | +| `GET /api/v3/orgs/{org}/invitations` | Pending invitations, each with an `id`. Owners and admins. | | |
| 403 | +| `DELETE /api/v3/orgs/{org}/invitations/{id}` | Revoke a pending invitation; returns `204`. Owners and admins; needs an `admin` token. | | |
| 404 | +| `GET /api/v3/orgs/{org}/repos` | Repositories you can see in the organization | | |
| 405 | +| `POST /api/v3/orgs/{org}/repos` | Create a repository in the organization. Needs a `write` or `admin` token. | | |
| 406 | +| `GET`, `PUT`, `DELETE /api/v3/orgs/{org}/actions/secrets[/{name}]` | Organization secrets. Needs an `admin` token. | | |
| 407 | +| `GET`, `POST`, `PATCH`, `DELETE /api/v3/orgs/{org}/actions/variables[/{name}]` | Organization variables. Owners and admins; changes need an `admin` token. | | |
| 408 | +| `PUT /api/v3/orgs/{org}/avatar` | Upload the organization's picture (raw image body). Owners only. | | |
| 409 | +| `DELETE /api/v3/orgs/{org}/avatar` | Remove the organization's picture. Owners only. | | |
| 410 | + | |
| 411 | +Organization objects in API responses follow GitHub's shape, with `"type": "Organization"`, `html_url` and `avatar_url`. | |
| 412 | + | |
| 413 | +Every endpoint in this table that changes something is an account-level action: besides the scope noted, the token must not be restricted to repositories, or the request gets `403 this token is limited to repositories and can't change account settings` (the picture endpoints answer `403 changing a profile picture needs a write or admin token that is not limited to repositories`). See [Personal access tokens](/docs/accounts/tokens#account-level-actions). | |
| 414 | + | |
| 415 | +Adding people by username, changing a member's role and removing a member are done in the web UI. A person accepts an invitation to an organization with `PATCH /api/v3/user/memberships/orgs/{org}` and `{"state": "active"}` (see [Invitations you receive](/docs/accounts/invitations#accept-or-decline-with-the-api)). The full list of endpoints is in the [API reference](/docs/api). | |
| 416 | + | |
| 417 | +## How organizations differ from GitHub | |
| 418 | + | |
| 419 | +- Diffinery has three organization roles: owner, admin and member. Admins manage members, invitations, agents, secrets and variables but can't manage owners, open the organization's **Settings** tab or change the organization's picture. | |
| 420 | +- The base permission works like GitHub's, with the same four values (`none`, `read`, `write`, `admin`) and the same default for new organizations (`read`). Organizations created before Diffinery had the setting use `write`. | |
| 421 | +- A member who creates a repository gets `admin` on it, as on GitHub. | |
| 422 | +- There are no teams. Give extra access per repository under **Settings → Collaborators**. | |
| 423 | +- **Add people** works like GitHub's: enter a username or an email address. Someone added by username gets an invitation and joins when they accept it under **Settings → Invitations**. An email address gets an invitation link, which also creates the account if there is none. | |
| 424 | +- Organization pages need you to be signed in. | |
| 425 | +- Organization secrets and variables apply to every repository in the organization; there is no per-repository selection. | |
| 426 | +- Deleting an organization is refused while it owns repositories or container images; GitHub deletes them with it. Transfer or delete them first. | |
| 427 | +- Repositories can be transferred only to your own account or to an organization you own or administer, so a transfer needs no acceptance by the new owner. |
addedapps/diffinery/docs/accounts/passkeys-and-sign-in.md+206−0View file
| @@ -1,0 +1,206 @@ | ||
| 1 | +--- | |
| 2 | +title: Passkeys and sign-in | |
| 3 | +description: How people sign in to Diffinery with passkeys, join from an invite or open sign-up, manage passkeys and browser sessions, and recover a lost passkey. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +People sign in to Diffinery in the browser with a passkey (WebAuthn). There are no passwords. This page covers signing in, joining from an invite or through open sign-up, adding and removing passkeys, managing signed-in browsers, the security emails you get, and how to get back in when you lose a passkey. | |
| 7 | + | |
| 8 | +Passkeys are only for the web UI. Git, the `diffinery` CLI, scripts and MCP clients use personal access tokens instead (see [Personal access tokens](/docs/accounts/tokens)); the CLI gets its own when you approve `diffinery login` in the browser (see [Install and sign in to the CLI](/docs/accounts/cli-sign-in)). For your username, display name, profile picture and light or dark theme, see [Profile and preferences](/docs/accounts/profile-and-preferences). | |
| 9 | + | |
| 10 | +## How passkey sign-in works | |
| 11 | + | |
| 12 | +A passkey is a key pair that your device, browser or password manager creates for `diffinery.com`. Diffinery stores only the public key. When you sign in, your device proves it holds the private key after you unlock it with your fingerprint, face, PIN or screen lock. | |
| 13 | + | |
| 14 | +Details that matter in practice: | |
| 15 | + | |
| 16 | +| Behaviour | Value | | |
| 17 | +| --- | --- | | |
| 18 | +| Where a passkey works | Only on the host it was created for (the hostname of the instance, `diffinery.com`). A passkey for one Diffinery instance does not work on another. | | |
| 19 | +| Kind of passkey | Discoverable credentials (resident keys). You don't type a username: the browser offers the passkeys it holds for the site. | | |
| 20 | +| User verification | Required. The device must unlock the passkey with a biometric, PIN or screen lock, not just a tap. | | |
| 21 | +| Attestation | None requested. Diffinery accepts any authenticator that can create a discoverable, user-verifying passkey. | | |
| 22 | +| Time to complete the browser prompt | 2 minutes. The sign-in attempt itself expires after 5 minutes. | | |
| 23 | +| Browser session length | 30 days, extended by another 30 days on each day you use it, up to 90 days after you signed in. | | |
| 24 | + | |
| 25 | +Diffinery is invite-only unless the instance turns on open sign-up. On an invite-only instance (the default) you can't create an account from the sign-in page: new people create their first passkey from an invite link (see [Join from an invite](#join-from-an-invite)). With open sign-up, the sign-in page also links to **Create an account** (see [Open sign-up](#open-sign-up)). | |
| 26 | + | |
| 27 | +## Sign in | |
| 28 | + | |
| 29 | +1. Open `https://diffinery.com/login`, or any page that needs you to be signed in. Diffinery redirects you to the sign-in page and back to that page afterwards. | |
| 30 | +2. Click **Sign in with a passkey**. | |
| 31 | +3. Your browser shows its passkey prompt. Pick the passkey for your Diffinery account, or choose to use a phone or a security key if the browser offers it. | |
| 32 | +4. Unlock the passkey with your fingerprint, face, PIN or screen lock. | |
| 33 | + | |
| 34 | +You land on the page you were trying to open (or your dashboard). To sign out, click **Sign out** in the header; that ends this browser's session only. | |
| 35 | + | |
| 36 | +The sign-in page also says how to recover a lost passkey on this instance: either a **Recover your account by email** link (when email recovery is set up) or "Ask an administrator for a sign-in link". | |
| 37 | + | |
| 38 | +## Join from an invite | |
| 39 | + | |
| 40 | +When someone invites you, Diffinery emails you a link like `https://diffinery.com/invite/<token>`. If you don't have an account yet, opening it shows a **Join Diffinery** page: | |
| 41 | + | |
| 42 | +1. Check the **Username** field. It is filled in from the part of your email address before the `@`; change it if you like. Usernames start with a letter or digit and use letters, digits, `-` and `_`, up to 39 characters. Some names are reserved (for example `api`, `settings`, `login`, `admin`, `docs`). | |
| 43 | +2. Optionally fill in a **Display name** (up to 100 characters). | |
| 44 | +3. Click **Create a passkey and join**, and complete your browser's passkey prompt. | |
| 45 | + | |
| 46 | +Diffinery creates your account, saves the passkey, signs you in and shows "Welcome to Diffinery, <username>. You can sign in with this passkey from now on." You land on the organization or repository the invite was for, or on your dashboard for an invite to the instance. You also get a welcome email. Creating the account accepts every open invite sent to your email address, so you join every organization or repository you were invited to at once. | |
| 47 | + | |
| 48 | +If the email address already has an account, the invite page asks you to sign in with that account's passkey first; after that the same page shows an **Accept invite** button. See [Invitations](/docs/accounts/invitations) for how invites are created, how long they last and what each kind grants. | |
| 49 | + | |
| 50 | +## Open sign-up | |
| 51 | + | |
| 52 | +An instance whose operator set `SIGNUP=open` (see [Configuration](/docs/self-hosting/configuration#signup)) lets anyone create an account. The sign-in page then says **New here? Create an account**, which leads to `/signup`: | |
| 53 | + | |
| 54 | +1. Enter your email address and, if the page shows one, complete the Cloudflare Turnstile check. Click **Email me a code**. | |
| 55 | +2. The page says "We sent a code to <address>". Find the email "Your sign-up code" and enter its 6 digits under **Code**, then click **Continue**. The code works once, for 10 minutes; a wrong code says how many tries are left (5 in all), and **send a new code** sends another one (up to 5 a day for an address). | |
| 56 | +3. Pick a **Username** and an optional **Display name**, as when joining from an invite, and click **Create a passkey and join**. This step must be finished within 30 minutes. | |
| 57 | + | |
| 58 | +Diffinery creates your account (active right away, no invite needed), saves the passkey, signs you in and sends a welcome email. Any open invites for your address are accepted at the same time. | |
| 59 | + | |
| 60 | +If the address already has an account, the page reads exactly the same, so nobody can use it to find out who has an account. No code is sent: the account's owner gets an email "Someone tried to sign up with your email address" with a link to sign in instead. | |
| 61 | + | |
| 62 | +On an invite-only instance `/signup` doesn't exist (`404`). Limits on codes are listed in [Limits](/docs/reference/limits#sign-in-and-sessions). | |
| 63 | + | |
| 64 | +## Manage your passkeys | |
| 65 | + | |
| 66 | +Go to **Settings → Passkeys and sessions** (`/settings/security`). The **Passkeys** box lists every passkey on your account with: | |
| 67 | + | |
| 68 | +- its name, | |
| 69 | +- a **synced** badge when the passkey is backed up by your password manager or platform (so it syncs to your other devices), or **this device only** when it is stored on a single device or security key, | |
| 70 | +- when it was added and when it was last used to sign in (or "never used to sign in"). | |
| 71 | + | |
| 72 | +### Add a passkey | |
| 73 | + | |
| 74 | +Add a passkey on each device you use, or keep one in a password manager that syncs, so losing one device doesn't lock you out. | |
| 75 | + | |
| 76 | +1. On the device or browser you want to add, sign in and open **Settings → Passkeys and sessions**. | |
| 77 | +2. Under **Add a passkey**, optionally type a name (up to 100 characters). If you leave it empty, Diffinery names it after the device and browser, for example `Mac · Safari` or `Android · Chrome`. | |
| 78 | +3. Click **Add a passkey** and complete the browser prompt. | |
| 79 | + | |
| 80 | +You see "Passkey "<name>" added." and Diffinery sends you a "A passkey was added to your account" email. | |
| 81 | + | |
| 82 | +If the browser says "This device already has a passkey for this account.", the authenticator you picked already holds a Diffinery passkey for you. Pick a different one (another device, a phone or a security key). | |
| 83 | + | |
| 84 | +### Remove a passkey | |
| 85 | + | |
| 86 | +Click **Remove** next to the passkey and confirm. Removing a passkey also signs out every other browser that signed in with it, which is what you want for a lost or stolen device. The browser you are using stays signed in. Diffinery sends you a "A passkey was removed from your account" email. | |
| 87 | + | |
| 88 | +You can't remove your last passkey. The list shows **Only passkey** instead of a **Remove** button, and the server refuses with "Add another passkey before removing your last one." Add a new passkey first, then remove the old one. | |
| 89 | + | |
| 90 | +Passkeys can only be managed from a signed-in browser. An API request made with an access token gets "Sign in with your passkey first." | |
| 91 | + | |
| 92 | +## Confirming it's you | |
| 93 | + | |
| 94 | +Adding or removing a passkey, creating a token or an agent, changing your username, deleting or transferring a repository or organization, exporting or deleting your account, and (for site administrators) deleting someone's account or changing who is a site administrator need a sign-in from the last 2 hours. If you signed in earlier, Diffinery shows **Confirm it's you**. Use your passkey (or **Confirm by email**, where email recovery is set up), and you go back to the page you were on. Then do the action again. See [Security overview](/docs/security/overview#confirming-its-you). | |
| 95 | + | |
| 96 | +## Sessions and signing out other browsers | |
| 97 | + | |
| 98 | +Each successful sign-in creates a browser session, stored in an HTTP-only, secure cookie (`__Host-dfy_session`) that only `diffinery.com` can read. A session lasts 30 days and is extended by another 30 days on each day you use it, so a browser you use regularly stays signed in. However often you use it, a session ends 90 days after you signed in, and you sign in with your passkey again. | |
| 99 | + | |
| 100 | +The **Signed-in browsers** box on **Settings → Passkeys and sessions** lists your active sessions (up to 100), newest activity first. Each row shows the device and browser, a **this browser** badge for the one you are using, when it signed in, how (a passkey, email recovery or a sign-in link) and when it was last active. | |
| 101 | + | |
| 102 | +To end one session, click **Sign out** on its row and confirm. You see "Signed out of that browser." To end every session except the current one, click **Sign out everywhere else** and confirm "Sign out every other browser?". You see "Signed out of every other browser." Those browsers need a passkey to sign in again. | |
| 103 | + | |
| 104 | +When a site administrator disables your account, every browser session ends at once, and signing in again is refused with "Your account is disabled." until the account is enabled. | |
| 105 | + | |
| 106 | +Signing out other browsers does not revoke access tokens. If you think someone has your token, revoke it under **Settings → Access tokens** (see [Personal access tokens](/docs/accounts/tokens#revoke-a-token)). | |
| 107 | + | |
| 108 | +## Security emails about sign-in | |
| 109 | + | |
| 110 | +These emails always send; you can't turn them off in notification settings. | |
| 111 | + | |
| 112 | +| Email | When it is sent | | |
| 113 | +| --- | --- | | |
| 114 | +| New sign-in to your account | You sign in with a passkey from a browser and passkey combination Diffinery hasn't seen for your account before, and every time you sign in through email recovery. It shows when, the device, the location (city and country) and the IP address. | | |
| 115 | +| A passkey was added to your account | You add a passkey in settings, or with a sign-in link from an administrator. | | |
| 116 | +| A passkey was removed from your account | You remove a passkey. | | |
| 117 | +| Welcome to Diffinery | You create your account from an invite or through open sign-up. | | |
| 118 | +| Someone tried to sign up with your email address | With open sign-up, someone asked for a sign-up code for your address. No account was created and no code was sent. | | |
| 119 | +| Your one-time sign-in link | A site administrator creates a sign-in link for you. | | |
| 120 | + | |
| 121 | +A browser is identified by the passkey used plus its operating system and browser name (for example `Mac · Chrome`). Signing in again from the same browser with the same passkey doesn't send another email. The device you joined or added a passkey from counts as known. | |
| 122 | + | |
| 123 | +If you get one of these emails and it wasn't you: open **Settings → Passkeys and sessions**, click **Sign out everywhere else**, remove any passkey you don't recognize, revoke any token you don't recognize under **Settings → Access tokens**, and tell a site administrator. | |
| 124 | + | |
| 125 | +See [Notifications](/docs/accounts/notifications) for every email Diffinery sends. | |
| 126 | + | |
| 127 | +## Recover your account | |
| 128 | + | |
| 129 | +If you have lost every passkey (or the device that held your only passkey), there are two ways back in. Both let you add a new passkey to your existing account; your repositories, tokens and settings are untouched. | |
| 130 | + | |
| 131 | +Recovering your account signs out every other browser signed in to it, in case the device you lost is one of them. Only browser sessions end: your access tokens keep working, so scripts and Git keep running. If you think someone has a token, revoke it under **Settings → Access tokens** once you are back in. The page you land on says "Every other browser signed in to your account was signed out; your access tokens keep working.", and the audit log records it as `session.revoke_others`. Email recovery from a browser that is already signed in to the same account (Diffinery sends you there to [confirm it's you](#confirming-its-you)) doesn't sign anything out. | |
| 132 | + | |
| 133 | +### Sign-in link from an administrator | |
| 134 | + | |
| 135 | +A site administrator can create a one-time sign-in link for you: | |
| 136 | + | |
| 137 | +1. The administrator opens **Settings → People and invitations**, finds you in the **Users** table and clicks **Sign-in link** (see [Site administration](/docs/admin/site-administration#sign-in-links)). | |
| 138 | +2. Diffinery emails you "Your one-time sign-in link". The administrator also sees the link and can send it to you another way. | |
| 139 | +3. Open the link (`https://diffinery.com/signin/<token>`). The **Add a passkey** page names the account it belongs to. | |
| 140 | +4. Optionally enter a **Name for this passkey**, click **Create a passkey** and complete the browser prompt. | |
| 141 | + | |
| 142 | +You are signed in and taken to **Settings → Passkeys and sessions** with "Passkey "<name>" added. Use it to sign in from now on." Every other browser signed in to your account is signed out. | |
| 143 | + | |
| 144 | +| Sign-in link rule | Value | | |
| 145 | +| --- | --- | | |
| 146 | +| Lifetime | 24 hours from when it was created | | |
| 147 | +| Uses | Once. After that, or after it expires, it shows "this sign-in link is invalid, already used, or expired". | | |
| 148 | +| Accounts | People only, and not disabled accounts | | |
| 149 | +| What it does | Adds a passkey to the account, signs that browser in and signs every other browser out. It doesn't remove your other passkeys or revoke your access tokens. | | |
| 150 | + | |
| 151 | +Anyone with the link can add a passkey to your account, so don't forward the email. After you are back in, remove the passkey of the device you lost. | |
| 152 | + | |
| 153 | +### Email recovery through /auth/access | |
| 154 | + | |
| 155 | +An instance can also offer recovery by email. This path is protected by Cloudflare Access, which checks that you control your email address with a one-time PIN. Diffinery then signs you in to the account with that email address. | |
| 156 | + | |
| 157 | +1. Open `https://diffinery.com/auth/access`, or click **Recover your account by email** on the sign-in page. | |
| 158 | +2. Enter your account's email address on the Cloudflare Access page. Cloudflare emails you a one-time PIN. | |
| 159 | +3. Enter the PIN. Cloudflare Access sends you back to Diffinery, which signs you in. | |
| 160 | +4. If your account has no passkeys, you land on **Settings → Passkeys and sessions** with "You're signed in. Add a passkey now so you can sign in directly next time." Add one right away. If you still have passkeys, you go to the page you were trying to open. | |
| 161 | + | |
| 162 | +Every email-recovery sign-in sends a "New sign-in to your account" email and is recorded in the audit log. Unless this browser was already signed in to the account, every other browser is signed out; your access tokens keep working. | |
| 163 | + | |
| 164 | +When email recovery isn't set up on an instance, `/auth/access` returns 404 with "Account recovery by email is not set up on this instance. Ask an administrator for a sign-in link.", and the sign-in page doesn't show the email recovery link. | |
| 165 | + | |
| 166 | +If the email address you verify has no account, Diffinery creates one that waits for an invite (see [Invitations](/docs/accounts/invitations#access-requests)), unless that address has an open invite or is a bootstrap site administrator. | |
| 167 | + | |
| 168 | +## Browser and device requirements | |
| 169 | + | |
| 170 | +- A browser with WebAuthn passkey support, such as a current version of Chrome, Safari, Edge or Firefox. In a browser without it, the sign-in button shows "This browser does not support passkeys. Try a current version of Chrome, Safari, Edge or Firefox." | |
| 171 | +- JavaScript turned on. Without it the page shows "Passkeys need JavaScript." | |
| 172 | +- An authenticator that can store a discoverable passkey and verify you: a phone or computer with a fingerprint reader, face unlock, PIN or screen lock, a password manager that supports passkeys, or a hardware security key that stores passkeys and has a PIN or biometric. | |
| 173 | +- The real site address. Passkeys are bound to `diffinery.com`, so they don't work through another hostname or a raw IP address. | |
| 174 | + | |
| 175 | +## Password managers and synced passkeys | |
| 176 | + | |
| 177 | +Passkeys saved in a password manager or your platform's keychain usually sync to your other devices, so one passkey covers your laptop and phone. Diffinery shows these with the **synced** badge. A passkey on a hardware security key or in a device-only store shows **this device only**; keep a second passkey somewhere else so you can't be locked out by losing that one key. | |
| 178 | + | |
| 179 | +If your browser offers several places to save the passkey when you create it, pick the password manager or keychain you use on all your devices. | |
| 180 | + | |
| 181 | +## How Diffinery sign-in differs from GitHub | |
| 182 | + | |
| 183 | +- Diffinery has no passwords and no username-and-password sign-in, so there is no password reset and no separate two-factor setup: the passkey's user verification is the second factor. | |
| 184 | +- Unless the instance turns on open sign-up, you can't sign up from the sign-in page: accounts come from invites. With open sign-up, an emailed code confirms your address instead of a password. | |
| 185 | +- Account recovery is a one-time link from a site administrator, or email recovery when the instance has it set up. There are no recovery codes. | |
| 186 | +- Git over HTTPS and the API never accept a passkey or browser session; they take a personal access token, much like GitHub. | |
| 187 | + | |
| 188 | +## Troubleshooting | |
| 189 | + | |
| 190 | +| Message or symptom | What it means and what to do | | |
| 191 | +| --- | --- | | |
| 192 | +| "The passkey prompt was closed or timed out. Try again when you are ready." | The browser prompt was cancelled, or 2 minutes passed. Click the button again. If no passkey is offered at all, this device has no passkey for Diffinery: use another device, or recover your account. | | |
| 193 | +| "This passkey is not registered with Diffinery. It may have been removed." | The passkey you picked was removed from your account (or belongs to another instance). Pick another passkey or recover your account. You can also delete the stale passkey from your device or password manager. | | |
| 194 | +| "This sign-in attempt expired. Please try again." | More than 5 minutes passed since the attempt started, or the browser didn't send the sign-in cookie back (for example, blocked cookies). Reload the page and try again. | | |
| 195 | +| "Passkey check failed: ..." | The browser's response didn't verify, for example because the page was opened on a different hostname. Open `https://diffinery.com/login` directly and try again. | | |
| 196 | +| "Your account is disabled." | A site administrator disabled your account. Ask them to enable it. | | |
| 197 | +| "This device already has a passkey for this account." | When adding a passkey: pick a different authenticator. | | |
| 198 | +| "This browser does not support passkeys..." | Update your browser or use another one. | | |
| 199 | +| "this invite is invalid, already used, or expired" | Ask the person who invited you for a new invite. | | |
| 200 | +| "That code has expired or was entered wrong too many times. Send a new code." | Sign-up codes last 10 minutes and allow 5 tries. Click **send a new code**. | | |
| 201 | +| "We have sent several codes to <address> today." | At most 5 sign-up codes a day go to one address. Check your spam folder, or try again tomorrow. | | |
| 202 | +| "Sign-up needs Turnstile configured" | The instance turned on open sign-up without Turnstile keys. Ask its administrators for an invite. | | |
| 203 | +| "this sign-in link is invalid, already used, or expired" | Sign-in links work once within 24 hours. Ask a site administrator for a new one. | | |
| 204 | +| "Waiting for an invite" page after email recovery | Your email address has no Diffinery account. Ask someone to invite that address, or click **Request access**. | | |
| 205 | +| Lost every passkey | Use [email recovery](#email-recovery-through-authaccess) if the sign-in page offers it, otherwise ask a site administrator for a [sign-in link](#sign-in-link-from-an-administrator). | | |
| 206 | +| `git` or the CLI asks for a password | Use a personal access token, not a passkey. See [Personal access tokens](/docs/accounts/tokens). | |
addedapps/diffinery/docs/accounts/permissions.md+250−0View file
| @@ -1,0 +1,250 @@ | ||
| 1 | +--- | |
| 2 | +title: Permissions | |
| 3 | +description: The repository roles (read, triage, write, maintain, admin), where roles come from, and how token scopes and agent policies cap them. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +Every request to a repository, whether it comes from the web UI, Git, the REST API or MCP, runs with one effective role on that repository. This page explains the five roles, where a person's role comes from, and how token scopes, repository-restricted tokens and agent policies lower it. | |
| 7 | + | |
| 8 | +## Repository roles | |
| 9 | + | |
| 10 | +Diffinery uses the same five roles as GitHub. Each role includes everything the roles before it can do. | |
| 11 | + | |
| 12 | +| Role | In short | | |
| 13 | +| --- | --- | | |
| 14 | +| `read` | View and clone; open issues and comment | | |
| 15 | +| `triage` | Manage issues, pull requests and labels without pushing code | | |
| 16 | +| `write` | Push, open and merge pull requests, run workflows | | |
| 17 | +| `maintain` | Also move or delete tags and edit other people's comments | | |
| 18 | +| `admin` | Settings, access, branch protection, secrets and webhooks | | |
| 19 | + | |
| 20 | +### What each role can do | |
| 21 | + | |
| 22 | +| Action | read | triage | write | maintain | admin | | |
| 23 | +| --- | --- | --- | --- | --- | --- | | |
| 24 | +| View code, history, issues, pull requests, workflow runs, logs and artifacts | Yes | Yes | Yes | Yes | Yes | | |
| 25 | +| Clone and fetch over Git, download Git LFS objects | Yes | Yes | Yes | Yes | Yes | | |
| 26 | +| List collaborators | Yes | Yes | Yes | Yes | Yes | | |
| 27 | +| Open issues, comment, and edit or close your own issues | Yes | Yes | Yes | Yes | Yes | | |
| 28 | +| Leave comment-only reviews and line comments on pull requests | Yes | Yes | Yes | Yes | Yes | | |
| 29 | +| Add and remove your own reactions | Yes | Yes | Yes | Yes | Yes | | |
| 30 | +| See published releases, download release assets and source archives (zip and tar) | Yes | Yes | Yes | Yes | Yes | | |
| 31 | +| See commit statuses, check runs, deployments and environments | Yes | Yes | Yes | Yes | Yes | | |
| 32 | +| Watch the repository and subscribe to threads | Yes | Yes | Yes | Yes | Yes | | |
| 33 | +| Create, apply and delete labels; set assignees (labels and assignees sent by someone below triage, when opening or editing an issue or pull request, are dropped) | No | Yes | Yes | Yes | Yes | | |
| 34 | +| Edit, close or reopen anyone's issue or pull request | No | Yes | Yes | Yes | Yes | | |
| 35 | +| Request reviewers on anyone's pull request | No | Yes | Yes | Yes | Yes | | |
| 36 | +| Lock and unlock conversations; comment and react on locked ones | No | Yes | Yes | Yes | Yes | | |
| 37 | +| Push branches and new tags; upload Git LFS objects | No | No | Yes | Yes | Yes | | |
| 38 | +| Create, update and delete branches and refs through the API; commit files in the web editor or API | No | No | Yes | Yes | Yes | | |
| 39 | +| Open pull requests | No | No | Yes | Yes | Yes | | |
| 40 | +| Approve or request changes on a pull request (not your own) | No | No | Yes | Yes | Yes | | |
| 41 | +| Merge pull requests, add them to or remove them from a merge queue, turn [auto-merge](/docs/collaboration/pull-requests#auto-merge) on or off | No | No | Yes | Yes | Yes | | |
| 42 | +| Resolve review threads | No | No | Yes | Yes | Yes | | |
| 43 | +| Run, cancel and re-run workflows | No | No | Yes | Yes | Yes | | |
| 44 | +| Report check runs and commit statuses (`POST /repos/{owner}/{repo}/check-runs`, `POST /repos/{owner}/{repo}/statuses/{sha}`) | No | No | Yes | Yes | Yes | | |
| 45 | +| Create, edit and delete releases and their assets; see draft releases | No | No | Yes | Yes | Yes | | |
| 46 | +| Create and delete deployments; report deployment statuses | No | No | Yes | Yes | Yes | | |
| 47 | +| Update a pull request's branch from its base (`PUT .../pulls/{number}/update-branch`) | No | No | Yes | Yes | Yes | | |
| 48 | +| Write Git objects through the API (blobs, trees, commits, tags, merges) | No | No | Yes | Yes | Yes | | |
| 49 | +| Trigger `repository_dispatch` (`POST /repos/{owner}/{repo}/dispatches`) | No | No | Yes | Yes | Yes | | |
| 50 | +| List Actions variables and environment variables | No | No | Yes | Yes | Yes | | |
| 51 | +| Start agent sandboxes | No | No | Yes | Yes | Yes | | |
| 52 | +| Move or delete existing tags | No | No | No | Yes | Yes | | |
| 53 | +| Edit or delete other people's comments | No | No | No | Yes | Yes | | |
| 54 | +| Dismiss a review on someone else's pull request (people only) | No | No | No | Yes | Yes | | |
| 55 | +| Change settings: name, description, visibility, default branch | No | No | No | No | Yes | | |
| 56 | +| Archive, unarchive and delete the repository | No | No | No | No | Yes | | |
| 57 | +| Transfer the repository to your own account or an organization you own or administer (people only; a personal repository only by its owner) | No | No | No | No | Yes | | |
| 58 | +| Add, change and remove collaborators; invite by email | No | No | No | No | Yes | | |
| 59 | +| Manage branch protection rules | No | No | No | No | Yes | | |
| 60 | +| List, set and delete Actions secrets; set and delete variables | No | No | No | No | Yes | | |
| 61 | +| Create, change and delete environments, their deployment branch policies and secrets (people only) | No | No | No | No | Yes | | |
| 62 | +| Set and delete environment variables | No | No | No | No | Yes | | |
| 63 | +| Set repository topics | No | No | No | No | Yes | | |
| 64 | +| Change the repository's workflow permissions setting: the default `GITHUB_TOKEN` permissions and whether workflows may create and approve pull requests (people only) | No | No | No | No | Yes | | |
| 65 | +| Delete other people's reactions | No | No | No | No | Yes | | |
| 66 | +| Manage webhooks | No | No | No | No | Yes | | |
| 67 | +| Run self-hosted runners for the repository (`diffinery runner`) | No | No | No | No | Yes | | |
| 68 | +| Bypass branch protection: push past it, merge past unmet rules by asking to (`"bypass_rules": true`), or merge without the merge queue by asking to (`"bypass_merge_queue": true`), unless the rule turns on **Do not allow administrators to bypass these rules** (people only for merges) | No | No | No | No | Yes | | |
| 69 | +| Commit past push protection with `git push -o allow-secrets=<reason>`, or with `allow_secrets` on `POST /repos/{owner}/{repo}/diffinery/commits` (people only) | No | No | No | No | Yes | | |
| 70 | + | |
| 71 | +Some actions have extra conditions on top of the role: | |
| 72 | + | |
| 73 | +- Branch protection can block pushes and merges for every role below admin, and for admins too when the rule enforces administrators. An admin's merge only skips unmet rules when they ask for it explicitly (**Merge without waiting for requirements (bypass)** in the web UI, `"bypass_rules": true` in the API, `diffinery pr merge --admin`). Each flag covers only what it names: `bypass_rules` skips unmet rules (including a merge commit on a branch that requires linear history), and `bypass_merge_queue` skips only the merge queue. Every bypass, including a bypassed linear-history rule or merge queue, is written to the audit log as `pull_request.merge_bypassed`. See [Branch protection](/docs/repositories/branch-protection). | |
| 74 | +- The `read` role's actions that change something (opening issues, commenting, reacting, comment-only reviews, watching) need a signed-in account. Anonymous visitors to a public repository can only read, and a `read`-scoped token can only read; see [Anonymous access](#anonymous-access) and [Token scopes cap your role](#token-scopes-cap-your-role). | |
| 75 | +- An archived repository refuses every write with `403 repository is archived (read-only)`, for every role and from the web UI, Git, the REST API and MCP alike: pushes, issues, comments, reviews, settings, collaborators, branch protection, secrets, variables and webhooks. Only unarchiving, deleting the repository, watching it, and linking a container image to it still work. See [Archive a repository](/docs/repositories/overview#archive-a-repository). | |
| 76 | +- You can't approve or request changes on your own pull request, and you can't dismiss reviews on it. | |
| 77 | +- Pull requests opened by agents always need at least one approval from a person. | |
| 78 | + | |
| 79 | +Container images have their own permissions. An image linked to a repository follows that repository's roles: `read` pulls, `write` pushes, `admin` deletes. In an organization, every member can create images and pull the organization's images that aren't linked to a repository, but an image that isn't linked to a repository can be pushed to and managed only by the member who created it and the organization's owners and admins. See [Container registry](/docs/packages/container-registry). | |
| 80 | + | |
| 81 | +## Where your role comes from | |
| 82 | + | |
| 83 | +Diffinery works out your role on each request, so a change takes effect immediately for the browser, Git and every token you hold. Your role on a repository is the highest of: | |
| 84 | + | |
| 85 | +| Source | Role | | |
| 86 | +| --- | --- | | |
| 87 | +| You own the repository (a personal repository) | admin | | |
| 88 | +| You are an owner or admin of the organization that owns it | admin | | |
| 89 | +| You are a member of the organization that owns it | the organization's base permission: `none`, `read`, `write` or `admin` (`read` for new organizations) | | |
| 90 | +| You created it in an organization as a member | admin (you are added as an `admin` collaborator) | | |
| 91 | +| You were added as a collaborator on the repository (**Settings → Collaborators**, an accepted repository invite, or `PUT /repos/{owner}/{repo}/collaborators/{username}`) | the role you were given | | |
| 92 | +| The repository is public | read | | |
| 93 | +| You are a site administrator | admin on every repository | | |
| 94 | + | |
| 95 | +Because the highest role wins, a collaborator entry can raise an organization member's access above the base permission but can't lower it. Accounts that are pending or disabled have no role anywhere. | |
| 96 | + | |
| 97 | +### Collaborators | |
| 98 | + | |
| 99 | +Repository admins manage collaborators under **Settings → Collaborators** (`/acme/website/settings/access`): | |
| 100 | + | |
| 101 | +- **Add people**: enter a **Username or email** (a person, an agent or an email address), pick a **Role** (**Read**, **Triage**, **Write**, **Maintain** or **Admin**; default **Write**) and click **Add**. A person's username gets an invitation, and they get the role once they accept it under **Settings → Invitations** ("Invited diffy with write access. They get access once they accept."). Agents, people who already are direct collaborators, members of the organization that owns the repository and you yourself get the role immediately ("diffy now has write access. We let them know."). An email address gets an invitation link; see [Invitations](/docs/accounts/invitations#add-people-to-a-repository). | |
| 102 | +- Change a collaborator's role with the **Role** menu and **Save**, or click **Remove** and confirm. | |
| 103 | + | |
| 104 | +The person gets an email when their access is granted, changed or removed, and an in-app notification when someone adds or invites them with **Add people**. With the API: | |
| 105 | + | |
| 106 | +```sh | |
| 107 | +# Invite diffy with the triage role, or change their role if they already are a collaborator | |
| 108 | +# (GitHub's "pull" and "push" also work: they mean read and write) | |
| 109 | +curl -X PUT https://diffinery.com/api/v3/repos/acme/website/collaborators/diffy \ | |
| 110 | + -H "Authorization: Bearer dfy_..." \ | |
| 111 | + -H "Content-Type: application/json" \ | |
| 112 | + -d '{"permission": "triage"}' | |
| 113 | + | |
| 114 | +# Remove diffy | |
| 115 | +curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/collaborators/diffy \ | |
| 116 | + -H "Authorization: Bearer dfy_..." | |
| 117 | +``` | |
| 118 | + | |
| 119 | +Without a body, `PUT` gives `write`. `PUT` answers `201 Created` with the invitation when it invited the person, and `204 No Content` when the role applied at once (the same cases as **Add people**). Both need admin on the repository and a token with the `admin` scope. Any other `permission` than `pull`, `read`, `triage`, `push`, `write`, `maintain` or `admin` answers `422 permission must be pull, triage, push, maintain or admin`, and an organization's login answers `422` too: add its members instead, or transfer the repository to it. | |
| 120 | + | |
| 121 | +## Organization roles | |
| 122 | + | |
| 123 | +Organizations have their own three roles, which map onto repository roles for every repository the organization owns. Members get the organization's base permission, which owners choose: | |
| 124 | + | |
| 125 | +| Organization role | Role on organization repositories | Can also | | |
| 126 | +| --- | --- | --- | | |
| 127 | +| member | The base permission: `none`, `read`, `write` or `admin` | Create repositories (and get `admin` on the ones they create) and container images in the organization | | |
| 128 | +| admin | admin | Manage members (except owners), invites, agents and organization secrets | | |
| 129 | +| owner | admin | Everything an admin can, plus manage owners and the organization's **Settings** tab (name, display name, base permission, picture) | | |
| 130 | + | |
| 131 | +New organizations start with a base permission of `read`; organizations that existed before the setting was added use `write`. Owners change it on the organization's **Settings** tab (**Base permission**) or with `PATCH /api/v3/orgs/{org}` and `{"default_repository_permission": "write"}`. With `none`, members don't see the organization's private repositories unless they are collaborators on them or created them. See [Base permission](/docs/accounts/organizations#base-permission) for details. | |
| 132 | + | |
| 133 | +## Site administrators | |
| 134 | + | |
| 135 | +Site administrators run the instance. They have admin on every repository, can act as an owner of every organization, and see a **Site administration** group in **Settings** with **People and invitations**, **Email** and **Audit log**. Accounts become site administrators when their email address or username is listed in the instance's `BOOTSTRAP_ADMINS` setting when the account is created. See [Site administration](/docs/admin/site-administration). | |
| 136 | + | |
| 137 | +A site administrator's tokens are still capped by their scopes, like everyone's. | |
| 138 | + | |
| 139 | +## Anonymous access | |
| 140 | + | |
| 141 | +Without signing in or sending a token, you have `read` on public repositories, but only to look: you can browse them, clone and fetch over Git, download LFS objects and call the API's read endpoints (`GET` and `HEAD`) for those repositories. Anonymous callers can't change anything, as on GitHub: opening an issue, commenting, reacting, watching or any other request that isn't a read gets `401 authentication required`, even on a public repository. Everything else, including MCP, needs an account. | |
| 142 | + | |
| 143 | +| Situation | Response | | |
| 144 | +| --- | --- | | |
| 145 | +| No credentials, private repository, or one that doesn't exist | Git, Git LFS and the web UI: `401 authentication required` (Git then asks for a username and password). REST API: `404`, as on GitHub. A signed-out caller can't tell the two apart. | | |
| 146 | +| No credentials, any change to a public repository (for example `POST /repos/{owner}/{repo}/issues`) | `401 authentication required` | | |
| 147 | +| Signed in or token, but no access to a private repository | `404 repository not found` (Diffinery doesn't reveal that it exists) | | |
| 148 | +| Access, but the role is too low | `403 <role> access required`, for example `403 write access required` | | |
| 149 | + | |
| 150 | +## Token scopes cap your role | |
| 151 | + | |
| 152 | +A personal access token never does more than its scope allows, whatever your role is. The cap is: | |
| 153 | + | |
| 154 | +| Token scope | Highest effective role | | |
| 155 | +| --- | --- | | |
| 156 | +| `read` | read | | |
| 157 | +| `write` | maintain | | |
| 158 | +| `admin` | admin | | |
| 159 | + | |
| 160 | +Your effective role through a token is the lower of your own role and the cap. If a token has several scopes, the highest one sets the cap. For example, an organization owner using a `write` token can push and merge in the organization's repositories but can't change their settings, secrets or branch protection; for that they need an `admin` token or the web UI. | |
| 161 | + | |
| 162 | +A `read` token goes further than the cap: it can't make any change at all. Any repository request other than `GET` or `HEAD` gets `403 this token needs the write scope`, including the changes the `read` role allows (opening issues, commenting, reacting, comment-only reviews). `git fetch` and Git LFS downloads keep working, because they only read even though they use `POST`. | |
| 163 | + | |
| 164 | +Browser sessions (signing in with a passkey) aren't capped: in the web UI you always act with your full role. | |
| 165 | + | |
| 166 | +Account-level actions check the scope directly, and most also refuse tokens restricted to repositories (`403 this token is limited to repositories and can't change account settings`): | |
| 167 | + | |
| 168 | +| Action | Needs | | |
| 169 | +| --- | --- | | |
| 170 | +| Create a repository (`POST /api/v3/user/repos`, `POST /api/v3/orgs/{org}/repos`) | a `write` or `admin` token that isn't restricted to repositories, or a browser session | | |
| 171 | +| Mark notifications read, change notification settings, send a test email | a `write` or `admin` token that isn't restricted to repositories, or a browser session | | |
| 172 | +| Watch or unwatch a repository | a `write` or `admin` token, or a browser session | | |
| 173 | +| Upload or remove your profile picture or an organization's (`PUT`/`DELETE /api/v3/user/avatar`, `/api/v3/orgs/{org}/avatar`; organization owners only) | a `write` or `admin` token that isn't restricted to repositories, or a browser session | | |
| 174 | +| Create a token (`POST /api/v3/diffinery/tokens`, `diffinery token create`) | an `admin` token or a browser session | | |
| 175 | +| Revoke a token, create an organization, invite people, manage organization secrets, create agents and agent tokens, disable agents | an `admin` token that isn't restricted to repositories, or a browser session | | |
| 176 | +| Send every email template (site administrators) | an `admin` token that isn't restricted to repositories, or a browser session | | |
| 177 | +| Change an organization's display name or base permission (`PATCH /api/v3/orgs/{org}`, owners only) | an `admin` token that isn't restricted to repositories, or a browser session | | |
| 178 | +| Change your username, display name or appearance | a browser session; there is no API | | |
| 179 | +| Add or remove passkeys, sign out other browsers | a browser session; tokens can't do this | | |
| 180 | + | |
| 181 | +A token whose scope is too low gets `403 this token needs the write scope` or `403 this token needs the admin scope`. | |
| 182 | + | |
| 183 | +See [Personal access tokens](/docs/accounts/tokens) for how to create tokens. | |
| 184 | + | |
| 185 | +## Repository-restricted tokens | |
| 186 | + | |
| 187 | +A token can be restricted to a list of repositories when you create it. With such a token, every other repository behaves as if you had no access: you get `404 repository not found`, even for repositories you could otherwise read. Inside the listed repositories, the usual role and scope cap apply. Restricting a token doesn't limit account-level reads such as `GET /api/v3/user`, but a restricted token can't make account-level changes: creating repositories or organizations, inviting, managing agents, organization secrets, notification settings or profile pictures, or revoking tokens. Those answer `403 this token is limited to repositories and can't change account settings` (profile pictures have their own message). | |
| 188 | + | |
| 189 | +## Agents | |
| 190 | + | |
| 191 | +Agents are accounts that act for a person or an organization. Their role on a repository is the lowest of: | |
| 192 | + | |
| 193 | +1. **The owner's role.** A personal agent can never do more than the person who owns it. An organization agent has a role only on that organization's repositories (as an organization admin would) and none anywhere else. | |
| 194 | +2. **The agent's cap.** By default an agent's maximum role is `write`. The owner can choose another **Maximum role** when creating the agent. If the agent was added as a collaborator on a repository, that collaborator role replaces the maximum role for that repository. | |
| 195 | +3. **The token's scope**, as for any token. | |
| 196 | + | |
| 197 | +On top of the role, these guardrails apply to agents everywhere (Git, the REST API, MCP and the web UI): | |
| 198 | + | |
| 199 | +- Agents can't push to the default branch or any protected branch, unless a protection rule turns on **Allow agents to push directly** (`agentsMayPush`). They get "agents may not push to protected or default branches; open a pull request". | |
| 200 | +- Agents can't merge into the default branch or a protected branch, unless the rule turns on **Allow agents to merge pull requests** (`agentsMayMerge`). | |
| 201 | +- A pull request opened by an agent needs at least one approving review from a person. Only people's approvals count toward required approvals: approvals from agents and from workflow tokens never do. | |
| 202 | +- Agents can never bypass push protection, and the merge-protection bypass for admins (`"bypass_rules": true`) applies only to people. | |
| 203 | +- Check runs and commit statuses reported by agents never satisfy a required status check. | |
| 204 | +- Agents can't dismiss reviews. | |
| 205 | +- Through the API, agents can't change branch protection, delete repositories, or manage environments. Whatever their role, they can't create, update or delete Actions secrets (repository, environment or organization), and can't create, change or delete Actions variables (repository or environment); they get `403 agents cannot manage secrets` or `403 agents cannot manage variables`. An agent with `write` or higher can still list variables. | |
| 206 | +- A workflow run started by an agent only gets an environment's secrets when the environment is limited to protected branches. Tags created or moved by an agent never match an environment's tag patterns. See [Environments](/docs/actions/environments). | |
| 207 | +- Commits an agent makes carry a `diffinery-agent` header naming the agent and the person it acts for. | |
| 208 | + | |
| 209 | +See [Agents](/docs/agents/overview) for creating and managing agents. | |
| 210 | + | |
| 211 | +## Job tokens and sandbox tokens | |
| 212 | + | |
| 213 | +Workflows and sandboxes get short-lived tokens of their own: | |
| 214 | + | |
| 215 | +| Token | Prefix | Where it comes from | What it can do on the repository | | |
| 216 | +| --- | --- | --- | --- | | |
| 217 | +| Job token | `djt_` | Each workflow job gets one as `GITHUB_TOKEN` (also `DIFFINERY_TOKEN`) | Act as `diffinery-actions[bot]` with what the workflow's `permissions:` grant (see below). Container images follow the job's `permissions: packages`: it pulls its own repository's images, its owner's images not linked to a repository and public images, and pushes only to images linked to its own repository (creating them, linked, on first push) | | |
| 218 | +| Sandbox token | `dst_` | Each agent sandbox gets one | Act as the person or agent that started the sandbox, capped at `write`, on that repository | | |
| 219 | +| Runner token | `drn_` | A self-hosted runner gets one when it registers with a repository admin's registration token | Claim that repository's queued jobs, with the labels it registered. Nothing else: no reads, pushes or other API calls. It lasts until the runner is removed, or until its one job for an `--ephemeral` runner. See [Manage runners](/docs/actions/runners#manage-runners) | | |
| 220 | + | |
| 221 | +Job and sandbox tokens can also read other public repositories (as anyone can), but no other private repository. They stop working when the job completes or the sandbox closes. See [Secrets and variables](/docs/actions/secrets-and-variables) and [Sandboxes](/docs/agents/sandboxes). | |
| 222 | + | |
| 223 | +### What a job token may do | |
| 224 | + | |
| 225 | +A job token follows GitHub's `permissions:` model. Its permissions come from the job's `permissions:`, else the workflow's, else the repository's default: | |
| 226 | + | |
| 227 | +| Repository default | Permissions without `permissions:` | | |
| 228 | +| --- | --- | | |
| 229 | +| Read (the default for new repositories) | `contents` and `packages` read, nothing else | | |
| 230 | +| Read and write (repositories created before the setting existed) | Write on every permission except `id-token`, which stays off | | |
| 231 | + | |
| 232 | +An admin changes the default, and whether workflows may create and approve pull requests (off by default), on the repository's **Settings → Actions** tab or with `PUT /api/v3/repos/{owner}/{repo}/actions/permissions/workflow`. See [Workflow permissions](/docs/actions/workflow-permissions) for the details. | |
| 233 | + | |
| 234 | +On top of its permissions: | |
| 235 | + | |
| 236 | +- Each endpoint needs its permission (`issues`, `pull-requests`, `contents`, `statuses`, `checks`, `deployments`, `actions` and so on). A request without it gets `403 Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: <scope>: <read or write>)`. Reads of a public repository are always allowed. | |
| 237 | +- The token's role is at most `write`, so branch protection always applies to it. Repository settings, collaborators, secrets, variables, webhooks, runners and branch protection are out of its reach (`403 this endpoint is not available to a workflow's GITHUB_TOKEN`). | |
| 238 | +- It never creates, changes or deletes files under `.github/workflows/` or `.diffinery/workflows/`, by push, API commit or merge. The refusal reads: refusing to allow a GitHub App to create or update workflow `<path>` without `workflows` permission. | |
| 239 | +- It can't open, approve or request changes on pull requests unless the repository allows it, and even then its approvals don't count toward required approvals. | |
| 240 | +- A reusable workflow never gets more than its caller. | |
| 241 | +- A run whose code came from an agent (an agent triggered it, pushed the branch last, or opened the pull request, and any re-run of such a run, whoever starts it, or `workflow_run` run that follows it) acts as an agent, so the agent guardrails apply: its token can't push or merge to the default branch or a protected branch. | |
| 242 | + | |
| 243 | +## How permissions differ from GitHub | |
| 244 | + | |
| 245 | +- The five repository roles match GitHub's names. Diffinery's `maintain` adds the ability to move or delete tags and to edit other people's comments; repository settings stay with `admin`. | |
| 246 | +- Opening a pull request needs `write`: its head branch must be a branch in the same repository. | |
| 247 | +- Organization members get the organization's base permission on every organization repository, as on GitHub, with the same values (`none`, `read`, `write`, `admin`) and the same default for new organizations (`read`). Organization owners and admins get `admin`. There are no teams or custom roles. | |
| 248 | +- Personal access tokens have three scopes (`read`, `write`, `admin`) instead of GitHub's fine-grained permissions; the scope caps your role. | |
| 249 | +- `GITHUB_TOKEN` in a workflow follows GitHub's `permissions:` model, but new repositories default to read-only tokens, the token never administers the repository or edits workflow files, and code that came from an agent runs under the agent rules. | |
| 250 | +- Agents are a separate account type with their own caps and guardrails. |
addedapps/diffinery/docs/accounts/profile-and-preferences.md+303−0View file
| @@ -1,0 +1,303 @@ | ||
| 1 | +--- | |
| 2 | +title: Profile and preferences | |
| 3 | +description: Change your username, display name and profile picture, choose light, dark or system appearance, and link your GitHub account for imports. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +Your profile is how other people see you on Diffinery: your username, display name and profile picture. This page also covers the **Appearance** setting (light, dark or following your system), **Linked accounts** (your GitHub account, for imports) and points to every other per-user setting under **Settings**. | |
| 7 | + | |
| 8 | +## Where your settings are | |
| 9 | + | |
| 10 | +Open **Settings** by clicking your picture and username at the top right of any page, or go to `https://diffinery.com/settings`. The sidebar has these pages: | |
| 11 | + | |
| 12 | +| Page | Path | What it holds | | |
| 13 | +| --- | --- | --- | | |
| 14 | +| **Profile** | `/settings` | Profile picture, username, display name, and read-only account details | | |
| 15 | +| **Account** | `/settings/account` | Exporting your data and deleting your account. See [Exporting and deleting your account](/docs/accounts/account-data). | | |
| 16 | +| **Appearance** | `/settings/appearance` | Light, dark or system theme | | |
| 17 | +| **Pull requests** | `/settings/pull-requests` | **Turn on auto-merge for pull requests I open**, and its merge method. See [Merge on green automatically](/docs/collaboration/merge-on-green#your-own-pull-requests). | | |
| 18 | +| **Passkeys and sessions** | `/settings/security` | Passkeys and signed-in browsers. See [Passkeys and sign-in](/docs/accounts/passkeys-and-sign-in). | | |
| 19 | +| **Access tokens** | `/settings/tokens` | Personal access tokens. See [Personal access tokens](/docs/accounts/tokens). | | |
| 20 | +| **Agents** | `/settings/agents` | Agent accounts you or your organizations own. See [Agents](/docs/agents/overview). | | |
| 21 | +| **Organizations** | `/settings/orgs` | Organizations you belong to, and creating new ones. See [Organizations](/docs/accounts/organizations). | | |
| 22 | +| **Notifications** | `/settings/notifications` | What you hear about in the notifications list and by email. See [Notifications](/docs/accounts/notifications). | | |
| 23 | +| **Linked accounts** | `/settings/linked-accounts` | GitHub accounts linked to yours, so imported work is credited to you. See [Linked accounts](#linked-accounts). | | |
| 24 | +| **Imports** | `/imports` | The GitHub imports you started, and **Import from GitHub**. See [Migrate from GitHub](/docs/migrate-from-github). | | |
| 25 | + | |
| 26 | +Site administrators also see a **Site administration** group with **People and invitations**, **Email** and **Audit log** (see [Site administration](/docs/admin/site-administration)). | |
| 27 | + | |
| 28 | +Every settings page needs you to be signed in in the browser. Profile, appearance and passkey changes can't be made with an access token, except the profile picture, which has an API (see [Profile picture API](#profile-picture-api)). | |
| 29 | + | |
| 30 | +## Your profile | |
| 31 | + | |
| 32 | +**Settings → Profile** (`/settings`) has two boxes: **Profile picture** (see [Profile pictures](#profile-pictures)) and a form with: | |
| 33 | + | |
| 34 | +| Field | Editable | Notes | | |
| 35 | +| --- | --- | --- | | |
| 36 | +| **Username** | Yes | Your login, the owner part of your repository URLs (`https://diffinery.com/diffy/...`) and your `@mention` handle | | |
| 37 | +| **Display name** | Yes | Optional. Shown on your profile page and in the settings sidebar, with your username under it, and returned as `name` by the API. If you leave it blank (or enter only spaces), Diffinery shows your username in its place and the API returns `"name": null`. | | |
| 38 | +| **Email** | No | Your account's email address. Notification emails go here. It can't be changed in Settings. | | |
| 39 | +| **Account type** | No | `user` for a person | | |
| 40 | +| **Joined** | No | When the account was created | | |
| 41 | +| **Site admin** | No | `yes` for site administrators, otherwise `no` | | |
| 42 | + | |
| 43 | +Change the username or display name and click **Save profile**. You see "Profile saved." | |
| 44 | + | |
| 45 | +Your public profile page is `https://diffinery.com/diffy`. It shows your picture, display name, username, your organizations and the repositories the viewer can see. On your own profile page there is an **Edit profile** button that opens **Settings → Profile**. Profile pages need the viewer to be signed in. | |
| 46 | + | |
| 47 | +### Change your username | |
| 48 | + | |
| 49 | +A username starts with a letter or digit, then uses letters, digits, `-` and `_`, up to 100 characters. Dots aren't allowed, and the name can't be reserved (for example `api`, `settings`, `login`, `admin`, `docs`) or already used by a person, organization or agent. The **Username** field checks the characters in your browser before you save, with the same rules as the server; the organization and agent name fields work the same way. | |
| 50 | + | |
| 51 | +Renaming changes the URL of every repository you own. The old URLs keep working (see [Old URLs after a transfer or rename](/docs/repositories/overview#old-urls-after-a-transfer-or-rename) for how each kind behaves), but update existing clones: | |
| 52 | + | |
| 53 | +```sh | |
| 54 | +git remote set-url origin https://diffinery.com/new-name/website.git | |
| 55 | +``` | |
| 56 | + | |
| 57 | +After a rename, `https://diffinery.com/old-name` and your repositories' old web and API URLs redirect to the new name, and Git and container images keep being served under the old name, until someone else takes the old name, which is free for them to take. Your picture moves with you: Diffinery forgets cached copies of the picture under the old name. | |
| 58 | + | |
| 59 | +| Message | What to do | | |
| 60 | +| --- | --- | | |
| 61 | +| "invalid or reserved login" | The name is reserved, or breaks the rules above. Pick another. | | |
| 62 | +| "login already taken" | Another person, organization or agent uses it. Pick another. | | |
| 63 | + | |
| 64 | +There is no API for changing your username or display name; use the web UI. `GET /api/v3/user` returns them as `login` and `name`. | |
| 65 | + | |
| 66 | +## Profile pictures | |
| 67 | + | |
| 68 | +People and organizations can upload a profile picture. Without one, Diffinery draws a picture from the first letter or digit of the name (see [Initials when there is no picture](#initials-when-there-is-no-picture)). Pictures appear next to your name everywhere in the web UI: the header, comments, pull requests, member and collaborator lists, and the user and organization pages. | |
| 69 | + | |
| 70 | +### Upload your picture in the web UI | |
| 71 | + | |
| 72 | +1. Go to **Settings → Profile** (`/settings`). | |
| 73 | +2. In the **Profile picture** box, under **Upload a new picture**, choose a file. The hint reads "JPEG, PNG or WebP, up to 5 MB. It is cropped to a centred square." | |
| 74 | +3. The current picture is replaced by a preview of the cropped square, with "Preview. Save to use this picture." If your browser can't open the file, you see "No preview for this file; it will be checked when you save." | |
| 75 | +4. Click **Save picture**. The status shows "Uploading…", then the page reloads with "Profile picture updated." | |
| 76 | + | |
| 77 | +Your browser does part of the work before uploading: it cuts out the centred square, shrinks it to at most 512 by 512 pixels and converts it to WebP (or PNG, if the browser can't produce WebP). The server then checks and processes that upload like any other. If the browser can't decode the file, it uploads the original file and the server's checks decide. | |
| 78 | + | |
| 79 | +### What Diffinery does with a picture | |
| 80 | + | |
| 81 | +| Step | Detail | | |
| 82 | +| --- | --- | | |
| 83 | +| Format check | The format is detected from the file's first bytes, not its name or `Content-Type`. JPEG, PNG and WebP are accepted. Anything else (GIF, HEIC, SVG, ...) is refused with "Use a JPEG, PNG or WebP image." | | |
| 84 | +| Size check | At most 5 MB (5,242,880 bytes) and at most 3072 by 3072 pixels | | |
| 85 | +| Crop | The centred square of the picture is kept; the rest of a non-square picture is cut off. There is no manual crop control. | | |
| 86 | +| Orientation | The EXIF orientation of a JPEG (as written by phone cameras) is applied, so the picture isn't sideways | | |
| 87 | +| Resize | The square is resized to 64, 128 and 256 pixels | | |
| 88 | +| Encode | Each size is saved as WebP (quality 80). The original upload isn't kept. | | |
| 89 | +| Replace | The previous picture's files are deleted | | |
| 90 | + | |
| 91 | +Transparent areas of a PNG or WebP stay transparent. | |
| 92 | + | |
| 93 | +### Limits for profile pictures | |
| 94 | + | |
| 95 | +| Limit | Value | | |
| 96 | +| --- | --- | | |
| 97 | +| Formats | JPEG, PNG, WebP | | |
| 98 | +| File size | 5 MB | | |
| 99 | +| Dimensions | 3072 by 3072 pixels | | |
| 100 | +| Stored sizes | 64, 128 and 256 pixels square, WebP | | |
| 101 | + | |
| 102 | +### Remove your picture | |
| 103 | + | |
| 104 | +When you have a picture, the **Profile picture** box has a **Remove picture** button ("Shows initials instead."). Click it and confirm "Remove your profile picture?". You see "Profile picture removed." and your initials come back everywhere. | |
| 105 | + | |
| 106 | +### Initials when there is no picture | |
| 107 | + | |
| 108 | +Without an uploaded picture, Diffinery serves a generated SVG picture: the first letter or digit of the name in white, upper case, on a colored background. The color is computed from the name, so it stays the same for that name. A name with no letters or digits shows `?`. | |
| 109 | + | |
| 110 | +In the web UI, people and organizations appear as circles and agents as rounded squares. | |
| 111 | + | |
| 112 | +### Organization pictures | |
| 113 | + | |
| 114 | +An organization's picture is managed on its **Settings** tab (`https://diffinery.com/acme?tab=settings`), which only organization owners (and site administrators) can open. It has the same **Profile picture** box: | |
| 115 | + | |
| 116 | +1. Open the organization, click **Settings**. | |
| 117 | +2. Under **Upload a new picture**, choose a file and click **Save picture**. You see "Organization picture updated." | |
| 118 | +3. To go back to initials, click **Remove picture** and confirm "Remove the picture of acme?". You see "Organization picture removed." | |
| 119 | + | |
| 120 | +Through the API, organization owners (and site administrators) can change the picture; see the next section. Organization admins and members can't change it. | |
| 121 | + | |
| 122 | +### Agent pictures | |
| 123 | + | |
| 124 | +The web UI has no upload control for agents, so they show initials until a picture is uploaded. An agent can set its own picture through the API with one of its own tokens, the same way a person does (`PUT /api/v3/user/avatar`). | |
| 125 | + | |
| 126 | +### Profile picture API | |
| 127 | + | |
| 128 | +These endpoints are a Diffinery extension (GitHub has no API for uploading a profile picture). The request body is the raw image file, not a form or JSON. | |
| 129 | + | |
| 130 | +| Method and path | Who | Response | | |
| 131 | +| --- | --- | --- | | |
| 132 | +| `PUT /api/v3/user/avatar` | You (the token's account) | `200` with `{"avatar_url": "..."}` | | |
| 133 | +| `DELETE /api/v3/user/avatar` | You | `200` with `{"avatar_url": "..."}` (the initials URL) | | |
| 134 | +| `PUT /api/v3/orgs/{org}/avatar` | Owners of the organization, site administrators | `200` with `{"avatar_url": "..."}` | | |
| 135 | +| `DELETE /api/v3/orgs/{org}/avatar` | Owners of the organization, site administrators | `200` with `{"avatar_url": "..."}` | | |
| 136 | + | |
| 137 | +The token needs the `write` or `admin` scope and must not be restricted to repositories. A browser session works too. | |
| 138 | + | |
| 139 | +Upload a picture: | |
| 140 | + | |
| 141 | +```sh | |
| 142 | +curl -X PUT https://diffinery.com/api/v3/user/avatar \ | |
| 143 | + -H "Authorization: Bearer dfy_..." \ | |
| 144 | + --data-binary @me.jpg | |
| 145 | +``` | |
| 146 | + | |
| 147 | +```json | |
| 148 | +{ "avatar_url": "https://diffinery.com/api/avatars/diffy?v=1760350000" } | |
| 149 | +``` | |
| 150 | + | |
| 151 | +The `v` value is the new picture's version. Upload an organization's picture: | |
| 152 | + | |
| 153 | +```sh | |
| 154 | +curl -X PUT https://diffinery.com/api/v3/orgs/acme/avatar \ | |
| 155 | + -H "Authorization: Bearer dfy_..." \ | |
| 156 | + --data-binary @logo.png | |
| 157 | +``` | |
| 158 | + | |
| 159 | +Remove it again: | |
| 160 | + | |
| 161 | +```sh | |
| 162 | +curl -X DELETE https://diffinery.com/api/v3/user/avatar -H "Authorization: Bearer dfy_..." | |
| 163 | +``` | |
| 164 | + | |
| 165 | +```json | |
| 166 | +{ "avatar_url": "https://diffinery.com/api/avatars/diffy" } | |
| 167 | +``` | |
| 168 | + | |
| 169 | +The `diffinery api` CLI command sends text bodies only, so use `curl` (or another HTTP client) to upload a picture. | |
| 170 | + | |
| 171 | +Errors: | |
| 172 | + | |
| 173 | +| Status | Message | Cause | | |
| 174 | +| --- | --- | --- | | |
| 175 | +| 401 | `sign in required` | No token was sent | | |
| 176 | +| 403 | `changing a profile picture needs a write or admin token that is not limited to repositories` | The token has only the `read` scope, or is restricted to repositories | | |
| 177 | +| 403 | `only organization owners can change its picture` | You are an admin or member of the organization, but not an owner | | |
| 178 | +| 404 | `not found` | The organization doesn't exist, or you aren't a member | | |
| 179 | +| 413 | `That picture is larger than 5 MB. Use a smaller file.` | The body is over 5 MB | | |
| 180 | +| 422 | `Send the picture as the request body.` | The request had no body | | |
| 181 | +| 422 | `Use a JPEG, PNG or WebP image.` | The body isn't JPEG, PNG or WebP | | |
| 182 | +| 422 | `That picture could not be read. Use a JPEG, PNG or WebP image.` | The file is damaged or its header can't be read | | |
| 183 | +| 422 | `That picture is 4000×3000 pixels; the limit is 3072×3072.` | The picture is larger than 3072 by 3072 pixels | | |
| 184 | + | |
| 185 | +Error bodies are JSON with a `message` field, like every API error. | |
| 186 | + | |
| 187 | +Every change is recorded in the audit log as `avatar.update` or `avatar.remove`, with the account kind and whether it came from the web UI or the API. | |
| 188 | + | |
| 189 | +### The avatar URL | |
| 190 | + | |
| 191 | +Every person, organization and agent has a picture at: | |
| 192 | + | |
| 193 | +```text | |
| 194 | +https://diffinery.com/api/avatars/{login} | |
| 195 | +``` | |
| 196 | + | |
| 197 | +This is the `avatar_url` in every user and organization object the REST API returns (for example `GET /api/v3/user`, the `user` of an issue, or `owner` of a repository), as on GitHub. | |
| 198 | + | |
| 199 | +| Query parameter | Values | Default | | |
| 200 | +| --- | --- | --- | | |
| 201 | +| `s` | Size in pixels. Diffinery serves the smallest stored size at least this big: `64`, `128` or `256`. Larger values get `256`. | `128` | | |
| 202 | +| `v` | A picture version, as returned by the upload API. Pins the URL to that picture so it can be cached for a long time. | none | | |
| 203 | + | |
| 204 | +```sh | |
| 205 | +# The 64-pixel picture of diffy | |
| 206 | +curl -o diffy.webp "https://diffinery.com/api/avatars/diffy?s=64" | |
| 207 | +``` | |
| 208 | + | |
| 209 | +The response is `image/webp` for an uploaded picture, or `image/svg+xml` for initials. The URL needs no sign-in or token, so you can use it in `<img>` tags, README files and other tools. A name that doesn't exist also gets an initials picture, so the URL doesn't reveal which accounts exist. | |
| 210 | + | |
| 211 | +### Caching | |
| 212 | + | |
| 213 | +| URL | `Cache-Control` | When a new picture shows up | | |
| 214 | +| --- | --- | --- | | |
| 215 | +| With the current `v` | `public, max-age=86400, immutable` | Never changes; a new picture gets a new `v` | | |
| 216 | +| Without `v` (or with an old `v`) | `public, max-age=600, stale-while-revalidate=86400` | Within about 10 minutes | | |
| 217 | + | |
| 218 | +The header, the settings pages, profile and organization pages and member lists use the versioned URL, so your new picture shows there right away after you save. Other places use the plain URL (for example issue and pull request conversations in the web UI, API clients and other sites), so they can show the old picture for up to about 10 minutes, and browsers may show a cached copy a little longer while they check for a new one. Diffinery also keeps a copy at Cloudflare's edge for up to 10 minutes; uploading or removing a picture clears that copy in the data center that handled the change, and other data centers pick up the change when their copy expires. | |
| 219 | + | |
| 220 | +## Appearance | |
| 221 | + | |
| 222 | +**Settings → Appearance** (`/settings/appearance`) sets the color theme of the web UI. The page says "Choose how Diffinery looks to you. The choice follows your account to every browser you sign in to." | |
| 223 | + | |
| 224 | +| Theme option | Description in the page | Behaviour | | |
| 225 | +| --- | --- | --- | | |
| 226 | +| **Sync with system** | "Light or dark, following your device's setting." | The default. Follows your operating system or browser's light or dark mode, and switches when it does. | | |
| 227 | +| **Light** | "Always light." | Always the light theme | | |
| 228 | +| **Dark** | "Always dark." | Always the dark theme | | |
| 229 | + | |
| 230 | +To change it: | |
| 231 | + | |
| 232 | +1. Go to **Settings → Appearance**. | |
| 233 | +2. Under **Theme**, pick **Sync with system**, **Light** or **Dark**. | |
| 234 | +3. Click **Save appearance**. You see "Appearance saved." | |
| 235 | + | |
| 236 | +### How the appearance setting is stored and applied | |
| 237 | + | |
| 238 | +- The choice is saved on your account, so every browser you sign in to uses it from the next page you open. There is no need to set it per device. | |
| 239 | +- The page is rendered with the theme already applied, so there is no flash of the wrong theme while it loads, and it works with JavaScript turned off. | |
| 240 | +- Saving **Light** or **Dark** also stores the choice in a cookie in the browser you saved it from (`dfy_theme`, kept for 400 days). Pages you open there while signed out, such as the sign-in page, invite pages and these docs, use it too. Saving **Sync with system** removes the cookie. | |
| 241 | +- In other browsers, pages you open while signed out follow the system setting until you sign in. | |
| 242 | +- The theme applies to every page of the web UI, including syntax-highlighted code and diffs, which use the GitHub Light Default and GitHub Dark Default color themes. | |
| 243 | +- Emails aren't affected: they follow the light or dark mode of your mail app. | |
| 244 | + | |
| 245 | +There is no API or CLI command for the appearance setting. | |
| 246 | + | |
| 247 | +## Linked accounts | |
| 248 | + | |
| 249 | +**Settings → Linked accounts** (`/settings/linked-accounts`) is the **Linked GitHub accounts** page. When someone [imports from GitHub](/docs/migrate-from-github), issues, pull requests, comments and reviews written by a linked GitHub account are credited to your Diffinery account instead of a placeholder ("mannequin") for the GitHub user. Link your account before the import runs: work that was already imported stays with the mannequin. | |
| 250 | + | |
| 251 | +### Link with a public gist | |
| 252 | + | |
| 253 | +No GitHub token is needed: | |
| 254 | + | |
| 255 | +1. Under **Step 1 · get a code**, leave **GitHub Enterprise API URL** blank for github.com, or enter your GitHub Enterprise API URL (for example `https://ghe.example.com/api/v3`), and click **Get my code**. | |
| 256 | +2. Copy the code (it starts with `diffinery-link-`). On GitHub, create a **public** gist whose description or one of whose files contains it. | |
| 257 | +3. Under **Step 2 · verify your gist**, enter **Your GitHub username on github.com** (or on your Enterprise host) and click **Verify and link**. You see "GitHub account linked." | |
| 258 | + | |
| 259 | +Diffinery reads your public profile and gists without a token, from that one GitHub host, and stores GitHub's numeric user id with the link, so a later rename of your GitHub account doesn't break it. You can delete the gist afterwards. | |
| 260 | + | |
| 261 | +### Link with GitHub OAuth | |
| 262 | + | |
| 263 | +If the site administrator configured a GitHub OAuth app, the page also has **Link with GitHub**: click **Continue to GitHub**, approve read-only access to your profile, and you come back with the account linked. OAuth links github.com accounts only; use a gist for GitHub Enterprise. | |
| 264 | + | |
| 265 | +### Manage your links | |
| 266 | + | |
| 267 | +- Each link shows the GitHub login, the host (`on github.com`) and when it was linked. Click **Unlink** and confirm "Unlink diffy?" to remove it; you see "Account unlinked." | |
| 268 | +- You can link several GitHub accounts (for example one on github.com and one on an Enterprise host). A GitHub account can be linked to only one Diffinery account. | |
| 269 | +- Only people can link accounts; agents can't. | |
| 270 | +- Linking, unlinking and a site administrator clearing a link are recorded in the audit log (`account.link_github`, `account.unlink_github`, `account.link_cleared`). | |
| 271 | +- The same actions are available in the REST API under `/api/v3/diffinery/user/linked-accounts` with a personal token that has the `admin` scope and isn't restricted to repositories. See [Link your GitHub account](/docs/migrate-from-github#link-your-github-account). | |
| 272 | + | |
| 273 | +| Message | What to do | | |
| 274 | +| --- | --- | | |
| 275 | +| "create a PUBLIC gist whose description or a file contains diffinery-link-..., then try again" | Make sure the gist is public (not secret) and contains the exact code, then click **Verify and link** again. | | |
| 276 | +| "GitHub user diffy was not found" | Check the username and, for Enterprise, the API URL. | | |
| 277 | +| "GitHub user diffy is linked to another Diffinery account" | Unlink it from that account first, or ask a site administrator to clear the link. | | |
| 278 | + | |
| 279 | +## Notification preferences | |
| 280 | + | |
| 281 | +Your notification preferences (which groups you hear about in the notifications list and by email, watching new repositories, emails about your own activity) are on **Settings → Notifications**, and can also be read and changed with `GET` and `PUT /api/v3/diffinery/notification-settings`. See [Notifications](/docs/accounts/notifications#notification-settings). | |
| 282 | + | |
| 283 | +## How profiles differ from GitHub | |
| 284 | + | |
| 285 | +- There is no bio, location, website, company, pronouns or status field. The profile has a username, a display name and a picture. | |
| 286 | +- Your email address comes from your invitation and isn't editable in Settings. Diffinery has no list of secondary email addresses. | |
| 287 | +- Profile pictures are uploaded to Diffinery. There is no Gravatar support (`gravatar_id` is always empty in API responses). | |
| 288 | +- Diffinery has an API to upload and remove profile pictures for users and organizations; GitHub doesn't. | |
| 289 | +- GitHub's theme settings have more options (for example separate day and night themes and high-contrast themes). Diffinery has light, dark and sync with system. | |
| 290 | +- After a username change, Diffinery redirects your profile and your repositories' old web, API and Git URLs until someone takes the old name; GitHub redirects the repositories only. | |
| 291 | + | |
| 292 | +## Troubleshooting | |
| 293 | + | |
| 294 | +| Message or symptom | What to do | | |
| 295 | +| --- | --- | | |
| 296 | +| "That picture is larger than 5 MB. Use a smaller file." | Use a file of 5 MB or less. In the web UI, files the browser can open are shrunk before uploading, so this mostly happens with the API or with files the browser can't preview. | | |
| 297 | +| "Use a JPEG, PNG or WebP image." | Convert the picture to JPEG, PNG or WebP. | | |
| 298 | +| "That picture is ... pixels; the limit is 3072×3072." | Resize the picture to at most 3072 by 3072 pixels, or upload it in the web UI, which shrinks it first. | | |
| 299 | +| "Choose a picture to upload." | Pick a file before clicking **Save picture**. | | |
| 300 | +| The old picture still shows in comments or in another tool | Those places use the unversioned avatar URL, which can be cached for about 10 minutes. Wait, or in your own tools use the URL with `?v=` from the upload response. | | |
| 301 | +| `403 changing a profile picture needs a write or admin token...` | Create a token with the `write` or `admin` scope and no repository restriction, under **Settings → Access tokens**. | | |
| 302 | +| The theme doesn't change on the sign-in page | Signed-out pages use the cookie of the browser where you last saved **Light** or **Dark**, or the system setting. Sign in, or save the setting again in this browser. | | |
| 303 | +| "login already taken" or "invalid or reserved login" | See [Change your username](#change-your-username). | |
addedapps/diffinery/docs/accounts/tokens.md+284−0View file
| @@ -1,0 +1,284 @@ | ||
| 1 | +--- | |
| 2 | +title: Personal access tokens | |
| 3 | +description: Create, scope, restrict, use and revoke Diffinery personal access tokens (dfy_) for Git, the REST API, the CLI and MCP clients. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +A personal access token lets Git, the `diffinery` CLI, scripts and MCP clients act as you. Tokens start with `dfy_`. You need one for anything outside the browser: passkeys only sign in to the web UI. | |
| 7 | + | |
| 8 | +## What a token is | |
| 9 | + | |
| 10 | +| Property | Value | | |
| 11 | +| --- | --- | | |
| 12 | +| Format | `dfy_` followed by 40 random characters (letters, digits, `-` and `_`) | | |
| 13 | +| Shown | Once, when you create it. Diffinery stores only a hash. | | |
| 14 | +| Prefix | The first 10 characters (for example `dfy_Ab12Cd`) are kept so you can recognize the token in lists and emails | | |
| 15 | +| Scopes | `read`, `write` or `admin` (default `write`) | | |
| 16 | +| Repository restriction | Optional list of repositories; default is every repository you can access | | |
| 17 | +| Lifetime | 1 to 366 days (default 90) | | |
| 18 | +| Last used | Recorded at most once an hour | | |
| 19 | + | |
| 20 | +A token acts as your account with your current role. If your access changes (you leave an organization, a collaborator role is removed), the token's reach changes on the next request. If your account is disabled, all your tokens are revoked. | |
| 21 | + | |
| 22 | +## Scopes | |
| 23 | + | |
| 24 | +The scope caps the role a token can use on a repository. Your effective role through a token is the lower of your own role and this cap. | |
| 25 | + | |
| 26 | +| Scope | Highest role | Typical use | | |
| 27 | +| --- | --- | --- | | |
| 28 | +| `read` | read | Clone and fetch, download Git LFS objects, read the API, CI that only downloads | | |
| 29 | +| `write` | maintain | Push, issues, pull requests, merging, running workflows; the right choice for most Git and CLI use | | |
| 30 | +| `admin` | admin | Everything you can do, including repository settings, collaborators, branch protection, secrets, webhooks, self-hosted runners and revoking tokens. No token can create tokens or agents | | |
| 31 | + | |
| 32 | +The web form describes them as: "read: clone and view · write: push, issues, pull requests, runs (up to maintain) · admin: everything you can do, including settings." | |
| 33 | + | |
| 34 | +### Read tokens never write | |
| 35 | + | |
| 36 | +A `read` token only reads. Every request that changes something in a repository (any method other than `GET` or `HEAD`) is refused with `403 this token needs the write scope`, even actions the `read` role itself allows, such as opening an issue, commenting, reacting or leaving a comment-only review. Two Git operations use `POST` but only read, and keep working with a `read` token: `git fetch` and `git clone` (`git-upload-pack`) and Git LFS downloads (an LFS batch request for `download`). Pushing and LFS uploads need `write`. | |
| 37 | + | |
| 38 | +The scope is checked after Diffinery has found a repository you can see, and before your role on it. So a `read` token that tries to change a private repository you can't see gets `404 repository not found`, the same answer as any other request for it, rather than a scope error that would reveal the repository exists. Use a `write` token for anything that opens, comments, pushes or merges, and an `admin` token for repository settings. | |
| 39 | + | |
| 40 | +### Account-level actions | |
| 41 | + | |
| 42 | +Actions that change your account or an organization, rather than a single repository, check the token's scope directly. Most of them also refuse tokens that are restricted to repositories, with `403 this token is limited to repositories and can't change account settings`. Browser sessions can do all of them. | |
| 43 | + | |
| 44 | +| Action | Token needs | Restricted tokens | | |
| 45 | +| --- | --- | --- | | |
| 46 | +| Create a repository (`POST /api/v3/user/repos`, `POST /api/v3/orgs/{org}/repos`, `diffinery repo create`) | `write` or `admin` | Refused | | |
| 47 | +| Mark notifications read (`PUT /api/v3/notifications`), change notification settings, send yourself a test email | `write` or `admin` | Refused | | |
| 48 | +| Watch or unwatch a repository (`PUT`/`DELETE /api/v3/repos/{owner}/{repo}/subscription`) | `write` or `admin` | Allowed for the listed repositories | | |
| 49 | +| Upload or remove a profile picture (`/api/v3/user/avatar`, `/api/v3/orgs/{org}/avatar`) | `write` or `admin` | Refused | | |
| 50 | +| Change an organization's display name or base permission (`PATCH /api/v3/orgs/{org}`, owners only) | `admin` | Refused | | |
| 51 | +| Create a token (`POST /api/v3/diffinery/tokens`, `diffinery token create`) | No token can: a browser session only | Refused | | |
| 52 | +| Revoke a token (`DELETE /api/v3/diffinery/tokens/{id}`, `diffinery token revoke`) | `admin` | Refused | | |
| 53 | +| Create an organization (`POST /api/v3/user/orgs`) | `admin` | Refused | | |
| 54 | +| Invite people (`POST /api/v3/orgs/{org}/invitations`, `POST /api/v3/invitations`, `diffinery invite`) | `admin` | Refused | | |
| 55 | +| Accept or decline an invitation you received (`PATCH`/`DELETE /api/v3/user/repository_invitations/{id}`, `PATCH /api/v3/user/memberships/orgs/{org}`) | `write` or `admin` | Refused | | |
| 56 | +| List, set and delete organization secrets (`/api/v3/orgs/{org}/actions/secrets`, `diffinery secret ... --org`) | `admin` | Refused | | |
| 57 | +| Create an agent or a token for an agent (`POST /api/v3/diffinery/agents`, `POST /api/v3/diffinery/agents/{login}/tokens`, `diffinery agent create`) | No token can: a browser session only | Refused | | |
| 58 | +| Disable an agent (`DELETE /api/v3/diffinery/agents/{login}`) | `admin` | Refused | | |
| 59 | +| Send every email template (site administrators) | `admin` | Refused | | |
| 60 | +| Manage passkeys and browser sessions; change your username, display name or appearance | Not possible with a token; use the browser | | | |
| 61 | + | |
| 62 | +A token whose scope is too low gets `403 this token needs the write scope` or `403 this token needs the admin scope`. Watching a repository with a `read` token gets `403 this needs a write or admin token`, and changing a profile picture with the wrong token gets `403 changing a profile picture needs a write or admin token that is not limited to repositories`. | |
| 63 | + | |
| 64 | +See [Permissions](/docs/accounts/permissions) for what each role allows. | |
| 65 | + | |
| 66 | +## Create a token | |
| 67 | + | |
| 68 | +Creating a token needs a sign-in from the last 2 hours. If yours is older, Diffinery asks you to [Confirm it's you](/docs/security/overview#confirming-its-you) with your passkey first. | |
| 69 | + | |
| 70 | +### In the web UI | |
| 71 | + | |
| 72 | +1. Go to **Settings → Access tokens** (`/settings/tokens`). | |
| 73 | +2. Under **Create a token**, fill in: | |
| 74 | + - **Name**: what the token is for, for example `laptop`, `ci` or `claude-code` (up to 80 characters). | |
| 75 | + - **Expires in (days)**: 1 to 366. The default is 90. | |
| 76 | + - **Scope**: **Read**, **Write** (default) or **Admin**. | |
| 77 | + - **Restrict to repositories** (optional): pick one or more repositories. Leave it empty for every repository you can access. | |
| 78 | +3. Click **Create token**. | |
| 79 | + | |
| 80 | +The page shows **Your new token** with a copy button. Copy it now: it is never shown again. Diffinery also emails you "A personal access token was added to your account". | |
| 81 | + | |
| 82 | +Your first token has to be created in the web UI; after that you can create more from the CLI or API with an `admin` token. | |
| 83 | + | |
| 84 | +### With the CLI | |
| 85 | + | |
| 86 | +You don't need to create a token for the CLI itself: `diffinery login` signs it in through your browser and creates its own token (see [Install and sign in to the CLI](/docs/accounts/cli-sign-in)). Install it with: | |
| 87 | + | |
| 88 | +```sh | |
| 89 | +curl -fsSL https://diffinery.com/install | bash | |
| 90 | +diffinery login | |
| 91 | +``` | |
| 92 | + | |
| 93 | +No token can create another token, so on a deployed instance `diffinery token create` gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`: create tokens in the web UI. The command works against a local development server (see [Deploy](/docs/self-hosting/deploy)): | |
| 94 | + | |
| 95 | +```sh | |
| 96 | +# A write token for CI, valid for 30 days, only for acme/website | |
| 97 | +diffinery token create --name ci --scope write --expires-days 30 -R acme/website | |
| 98 | +``` | |
| 99 | + | |
| 100 | +| Option | Description | | |
| 101 | +| --- | --- | | |
| 102 | +| `-n, --name NAME` | Token name. Required. | | |
| 103 | +| `-s, --scope SCOPE` | `read`, `write` (default) or `admin`. Repeatable, or comma-separated. | | |
| 104 | +| `-e, --expires-days DAYS` | Days until it expires, 1 to 366 (default 90). | | |
| 105 | +| `-R, --repo OWNER/NAME` | Restrict the token to this repository. Repeatable. | | |
| 106 | +| `--json` | Print `token`, `id` and `expiresAt` as JSON. | | |
| 107 | + | |
| 108 | +The token is printed on standard output (so you can pipe it), and the ID and expiry date on standard error. | |
| 109 | + | |
| 110 | +### With the API | |
| 111 | + | |
| 112 | +`POST /api/v3/diffinery/tokens`, from a browser session (the session cookie you get by signing in with a passkey). No token can create a token, whatever its scope, so a leaked or limited token can never turn itself into a wider or longer-lived one: | |
| 113 | + | |
| 114 | +```sh | |
| 115 | +curl -X POST https://diffinery.com/api/v3/diffinery/tokens \ | |
| 116 | + -H "Cookie: __Host-dfy_session=..." \ | |
| 117 | + -H "Content-Type: application/json" \ | |
| 118 | + -d '{"name": "ci", "scopes": ["write"], "repositories": ["acme/website"], "expires_in_days": 30}' | |
| 119 | +``` | |
| 120 | + | |
| 121 | +| Field | Type | Description | | |
| 122 | +| --- | --- | --- | | |
| 123 | +| `name` | string | Token name | | |
| 124 | +| `scopes` | array of strings | One or more of `read`, `write`, `admin`. Omit it (or send `null`) for the default `["write"]`. An empty list, a value that isn't a list, or an unknown name returns `422 scopes must be a non-empty list of: read, write, admin`. Repeated names are stored once. | | |
| 125 | +| `repositories` | array of strings | `owner/name` of each repository to restrict to. Omit for every repository. You can only list repositories your account can see: a repository that doesn't exist, or a private one you have no access to, returns `422 unknown repository <name>` (the same message for both, so the answer doesn't reveal private repositories). | | |
| 126 | +| `expires_in_days` | number | 1 to 366 (values outside are clamped). Default 90. | | |
| 127 | + | |
| 128 | +Response (`201 Created`): | |
| 129 | + | |
| 130 | +```json | |
| 131 | +{ "token": "dfy_...", "id": "9b2f0c1d4e5a6b7c8d9e0f1a2b3c4d5e", "expiresAt": "2027-01-11T09:30:00.000Z" } | |
| 132 | +``` | |
| 133 | + | |
| 134 | +Any request authenticated with a token (personal, agent, job or sandbox) gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`. The CLI checks scope names before sending and exits with `unknown scope "<name>" (read, write or admin)`. | |
| 135 | + | |
| 136 | +## Restrict a token to repositories | |
| 137 | + | |
| 138 | +A restricted token only works on the repositories you picked. Any other repository answers `404 repository not found`, as if it didn't exist, even if you could normally read it. Inside the listed repositories the usual role and scope cap apply. Repository lists (`GET /api/v3/user/repos`, `/users/{login}/repos`, `/orgs/{org}/repos` and the MCP `list_repos` tool) show only the listed repositories, and reading notifications, which cover every repository, answers `403 this token is limited to repositories and can't read notifications`. Other account-level reads (such as `GET /api/v3/user` or listing your tokens) still work, but account-level changes don't: creating repositories or organizations, inviting people, managing agents, organization secrets, notification settings and profile pictures, and creating or revoking tokens all answer `403 this token is limited to repositories and can't change account settings` (profile pictures have their own message). See [Account-level actions](#account-level-actions). | |
| 139 | + | |
| 140 | +Restrict tokens you hand to CI systems, agents and third-party tools to the repositories they need. | |
| 141 | + | |
| 142 | +## List your tokens | |
| 143 | + | |
| 144 | +The **Active tokens** table on **Settings → Access tokens** shows each token's **Name** and prefix, **Scopes**, **Repositories** (or "all"), **Expires** date and **Last used** time ("never" if it hasn't been used). Revoked tokens aren't listed. | |
| 145 | + | |
| 146 | +```sh | |
| 147 | +diffinery token list | |
| 148 | +``` | |
| 149 | + | |
| 150 | +```sh | |
| 151 | +curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/diffinery/tokens | |
| 152 | +``` | |
| 153 | + | |
| 154 | +The API returns an array of objects with `id`, `name`, `prefix`, `scopes`, `repoIds` (`null` when unrestricted), `expiresAt`, `lastUsedAt` and `createdAt`. | |
| 155 | + | |
| 156 | +## Expiry and reminder emails | |
| 157 | + | |
| 158 | +Every token expires; you can't create one that doesn't. After the expiry date, requests with it fail with `401 invalid, expired or revoked token`. | |
| 159 | + | |
| 160 | +About a week before a token expires, Diffinery emails you "Your token "<name>" expires on <date>", with the token's prefix and a **Create a new token** button. The reminder is sent once per token, and only for tokens that were created to last more than 8 days. To replace a token, create a new one, swap it in wherever the old one is used (Git credentials, CI secrets, MCP client settings), then revoke the old one. | |
| 161 | + | |
| 162 | +## Revoke a token | |
| 163 | + | |
| 164 | +- **Web UI:** click **Revoke** next to the token on **Settings → Access tokens** and confirm. You see "Token revoked." | |
| 165 | +- **CLI:** `diffinery token revoke <id>` (the ID is in `diffinery token list`). | |
| 166 | +- **API:** `DELETE /api/v3/diffinery/tokens/{id}`, which returns `204`. Revoking through the API or CLI needs an `admin` token that isn't restricted to repositories. | |
| 167 | + | |
| 168 | +A revoked token stops working immediately and can't be restored. Anything still using it gets `401 invalid, expired or revoked token`. | |
| 169 | + | |
| 170 | +## Use a token | |
| 171 | + | |
| 172 | +### Git over HTTPS | |
| 173 | + | |
| 174 | +Use any username and the token as the password: | |
| 175 | + | |
| 176 | +```sh | |
| 177 | +git clone https://diffinery.com/acme/website.git | |
| 178 | +# Username: diffy (anything works) | |
| 179 | +# Password: dfy_... | |
| 180 | +``` | |
| 181 | + | |
| 182 | +To avoid typing it, let the CLI answer Git's credential prompts for `diffinery.com`: | |
| 183 | + | |
| 184 | +```sh | |
| 185 | +diffinery login # paste the token once; saved in ~/.config/diffinery/config.json (mode 0600) | |
| 186 | +diffinery auth setup-git # git uses the saved token for https://diffinery.com | |
| 187 | +``` | |
| 188 | + | |
| 189 | +`diffinery auth setup-git` configures `credential.https://diffinery.com.helper` in your global Git config. You can also use Git's own helpers, for example `git config --global credential.helper store`, or your operating system's keychain helper. See [Git](/docs/repositories/git). | |
| 190 | + | |
| 191 | +### curl and other HTTP clients | |
| 192 | + | |
| 193 | +Send the token as a bearer token: | |
| 194 | + | |
| 195 | +```sh | |
| 196 | +curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/user | |
| 197 | +``` | |
| 198 | + | |
| 199 | +Diffinery accepts these `Authorization` forms: | |
| 200 | + | |
| 201 | +| Form | Example | | |
| 202 | +| --- | --- | | |
| 203 | +| Bearer | `Authorization: Bearer dfy_...` | | |
| 204 | +| Token (GitHub style) | `Authorization: token dfy_...` | | |
| 205 | +| Basic | `Authorization: Basic base64(username:dfy_...)`. The password is used; the username can be anything. If the password is empty, the username is used as the token. | | |
| 206 | + | |
| 207 | +Git uses Basic authentication; the CLI, MCP clients and most scripts use Bearer. They are equivalent. A `Basic` value that isn't valid base64 is refused with `401 malformed basic credentials`. | |
| 208 | + | |
| 209 | +If a request carries the `Authorization` header twice (Git LFS does this when you set `http.extraHeader` and it also sends its own header), Diffinery uses the first one. | |
| 210 | + | |
| 211 | +### The CLI | |
| 212 | + | |
| 213 | +`diffinery login` signs the CLI in through your browser and saves the server URL and the token it gets in `~/.config/diffinery/config.json` (or under `$XDG_CONFIG_HOME`). Those tokens are listed here with a **CLI sign-in** badge and named after the computer; see [Install and sign in to the CLI](/docs/accounts/cli-sign-in). `diffinery login --token dfy_...` saves a token you created instead. The environment variables `DIFFINERY_TOKEN` and `DIFFINERY_URL` override the saved values, which is handy in CI: | |
| 214 | + | |
| 215 | +```sh | |
| 216 | +export DIFFINERY_TOKEN=dfy_... | |
| 217 | +diffinery whoami | |
| 218 | +``` | |
| 219 | + | |
| 220 | +`diffinery logout` removes the saved token, and revokes it when a CLI sign-in created it (a token you pasted may be in use elsewhere, so it's left alone). See [CLI reference](/docs/reference/cli). | |
| 221 | + | |
| 222 | +### MCP clients | |
| 223 | + | |
| 224 | +Point the client at `https://diffinery.com/mcp` with the token in an `Authorization` header. `diffinery mcp-config` prints a ready-made snippet: | |
| 225 | + | |
| 226 | +```json | |
| 227 | +{ "mcpServers": { "diffinery": { "type": "http", "url": "https://diffinery.com/mcp", "headers": { "Authorization": "Bearer ${DIFFINERY_TOKEN}" } } } } | |
| 228 | +``` | |
| 229 | + | |
| 230 | +By default the snippet refers to `${DIFFINERY_TOKEN}` from the environment; `diffinery mcp-config --show-token` embeds the saved token instead. For an AI agent, create an agent account with its own token rather than giving it yours. See [MCP](/docs/agents/mcp) and [Agents](/docs/agents/overview). | |
| 231 | + | |
| 232 | +## Agent tokens | |
| 233 | + | |
| 234 | +Agents have their own `dfy_` tokens, created by their owner under **Settings → Agents** (default lifetime 30 days there), with `diffinery agent create`, or with `POST /api/v3/diffinery/agents/{login}/tokens`. Creating them through the API or CLI needs an `admin` token that isn't restricted to repositories; the `scopes` field follows the same rules as for personal tokens (default `["write"]`, `422` for an empty list or unknown name). They work like personal tokens but act as the agent, within its policy. Their creation and expiry emails go to the agent's owner (or, for an organization agent, the organization's owners and admins). See [Agents](/docs/agents/overview). | |
| 235 | + | |
| 236 | +## Job tokens and sandbox tokens | |
| 237 | + | |
| 238 | +Two other kinds of token exist. You don't create them yourself: | |
| 239 | + | |
| 240 | +- **Job tokens** (`djt_`) are given to each workflow job as `GITHUB_TOKEN` and `DIFFINERY_TOKEN`. They act as `diffinery-actions[bot]` on the repository the job runs in, with what the workflow's `permissions:` grant, as on GitHub: each API endpoint needs its permission (`contents`, `issues`, `pull-requests`, `statuses`, `checks` and so on), and a request without it gets `403 Resource not accessible by integration`. Without `permissions:` a job gets the repository's default, which is read-only (`contents` and `packages` read) for new repositories; an admin changes it with the repository's workflow permissions setting (`PUT /api/v3/repos/{owner}/{repo}/actions/permissions/workflow`). A job token's role never goes above write, so branch protection applies to it, and it never changes repository settings, secrets, variables, webhooks, runners or branch protection, or creates or changes workflow files. A run whose code came from an agent acts as an agent, so it can't push or merge to the default or protected branches. The token also reads other public repositories (not other private ones), uses the container registry according to `permissions: packages`, and stops working when the job finishes. See [Workflow permissions](/docs/actions/workflow-permissions) and [Secrets and variables](/docs/actions/secrets-and-variables). | |
| 241 | +- **Sandbox tokens** (`dst_`) are given to each agent sandbox. They act as whoever started the sandbox, capped at write, on that repository, can also read other public repositories, and stop working when the sandbox closes. See [Sandboxes](/docs/agents/sandboxes). | |
| 242 | + | |
| 243 | +Self-hosted runners have tokens of their own too. A repository admin creates a **registration token** (`drg_`, valid for an hour), and each runner trades it for a **runner token** (`drn_`) that can claim that repository's jobs and nothing else: it can't read the repository, push or call any other API. Removing the runner ends its token. See [Manage runners](/docs/actions/runners#manage-runners). | |
| 244 | + | |
| 245 | +[Push protection](/docs/security/push-protection) blocks a push or commit that contains a job, sandbox or [agent task](/docs/agents/tasks) model token (`dmt_`), as it does for `dfy_` tokens, so one printed into a file or a commit message by a job or an agent isn't stored in the repository. | |
| 246 | + | |
| 247 | +## Security emails about tokens | |
| 248 | + | |
| 249 | +| Email | Sent to | When | | |
| 250 | +| --- | --- | --- | | |
| 251 | +| A personal access token was added to your account | You | Every time a token is created for you, from the web UI, the CLI or the API | | |
| 252 | +| A token was created for the agent <agent> | The agent's owner, or its organization's owners and admins | A token is created for an agent | | |
| 253 | +| Your token "<name>" expires on <date> | The same people | About 7 days before expiry (once per token, for tokens with a lifetime over 8 days) | | |
| 254 | + | |
| 255 | +These can't be turned off. If you get one for a token you didn't create, revoke it right away. | |
| 256 | + | |
| 257 | +## How tokens differ from GitHub | |
| 258 | + | |
| 259 | +- Diffinery has one kind of personal token with three scopes, plus an optional repository list. GitHub has classic tokens with many scopes and fine-grained tokens with per-permission settings. | |
| 260 | +- Every token expires, after at most 366 days. | |
| 261 | +- Tokens can only be created with the API or CLI when you already hold an `admin`-scoped token that isn't restricted to repositories; the first one comes from the web UI. | |
| 262 | +- Git accepts a token as the password with any username, as on GitHub. | |
| 263 | + | |
| 264 | +## Troubleshooting | |
| 265 | + | |
| 266 | +| Message | Cause and fix | | |
| 267 | +| --- | --- | | |
| 268 | +| `401 invalid, expired or revoked token` | The token is unknown, expired or revoked, or your account is disabled or not yet active. Create a new token. | | |
| 269 | +| `401 unrecognised token; create one under Settings → Access tokens` | The credential doesn't start with `dfy_` (or `djt_`, `dst_`, `drn_`), for example a GitHub token or a password. Use a Diffinery token from **Settings → Access tokens**. | | |
| 270 | +| `401 authentication required` | No token was sent to a private repository. Configure Git credentials or add the `Authorization` header. | | |
| 271 | +| `404 repository not found` | You have no access, or the token is restricted to other repositories. A `read` token that tries to change a private repository it can't see also gets this. | | |
| 272 | +| `401 malformed basic credentials` | The `Authorization: Basic` value isn't valid base64. Encode `username:token` with base64, or send `Authorization: Bearer dfy_...` instead. | | |
| 273 | +| `422 unknown repository <name>` when creating a token | The repository doesn't exist, or it is private and your account can't see it. Check the `owner/name` spelling and your access. | | |
| 274 | +| `401 authentication required` on a public repository | You sent no token for a change (opening an issue, commenting, reacting, watching). Anonymous callers can only read. Send a `write` token. | | |
| 275 | +| `403 write access required` (or another role) | Your role, or the token's scope, is too low. For `admin` actions, use an `admin` token. | | |
| 276 | +| `403 this token needs the write scope` | A `read` token tried to change something. Use a `write` or `admin` token. | | |
| 277 | +| `403 this token needs the admin scope` | Account-level actions such as inviting, creating organizations or agents, organization secrets, and creating or revoking tokens need an `admin` token. | | |
| 278 | +| `403 this token is limited to repositories and can't change account settings` | The token has a repository list. Use a token without one, or the web UI. | | |
| 279 | +| `422 scopes must be a non-empty list of: read, write, admin` | The `scopes` field was empty, not a list, or had an unknown name. | | |
| 280 | +| `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens` | Tokens can't create tokens. Create it in the web UI. | | |
| 281 | +| `403 this needs a write or admin token` | Watching or unwatching a repository needs a `write` or `admin` token. | | |
| 282 | +| `403 changing a profile picture needs a write or admin token that is not limited to repositories` | Use a `write` or `admin` token without a repository restriction, or the web UI. | | |
| 283 | +| The CLI adds "token invalid or expired; run `diffinery login`" | Run `diffinery login` with a new token. If `DIFFINERY_TOKEN` is set, it overrides the saved token. | | |
| 284 | +| Git keeps asking for a password | Your credential helper has an old token. Run `diffinery auth setup-git`, or update the stored credential. | |
addedapps/diffinery/docs/actions/caching-and-artifacts.md+555−0View file
| @@ -1,0 +1,555 @@ | ||
| 1 | +--- | |
| 2 | +title: Caching and artifacts | |
| 3 | +description: Speed up workflows with actions/cache, pass files between jobs and runs with upload-artifact and download-artifact, and manage both through the API. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +A cache keeps files such as dependency downloads between runs. An artifact keeps files produced by a run, so later jobs (in the same run or another one) can use them and you can download them afterwards. Diffinery serves the same cache and artifact services as GitHub, so the official `actions/cache`, `actions/upload-artifact` and `actions/download-artifact` work unmodified, and the runner also has faster built-in versions that share their storage and formats. | |
| 7 | + | |
| 8 | +## Built-in actions and the official actions | |
| 9 | + | |
| 10 | +For speed, the runner runs its own implementation of these actions instead of downloading them: | |
| 11 | + | |
| 12 | +| Action | Built-in for | What it does | | |
| 13 | +| --- | --- | --- | | |
| 14 | +| `actions/cache` | `v3` to `v6` | Restores a cache entry, then saves one in a post step when the job ends | | |
| 15 | +| `actions/cache/restore` | `v3` to `v6` | Restores a cache entry only | | |
| 16 | +| `actions/cache/save` | `v3` to `v6` | Saves a cache entry only | | |
| 17 | +| `actions/upload-artifact` | `v4` to `v7` | Uploads files as an artifact of the run | | |
| 18 | +| `actions/download-artifact` | `v4` to `v8` | Downloads artifacts of this run, or of another run with `run-id` and `github-token` | | |
| 19 | + | |
| 20 | +The built-ins use the same services, archive formats, cache versions and artifact ids as the official actions. An entry saved by the built-in `actions/cache` restores with the official action and the other way round, and an artifact uploaded by either downloads with either. | |
| 21 | + | |
| 22 | +A built-in steps aside and the real action is downloaded from GitHub and run when: | |
| 23 | + | |
| 24 | +- the reference isn't a version tag in the range above (`vN`, `vN.N` or `vN.N.N`), for example a commit SHA, a branch, or a newer major version; | |
| 25 | +- the step passes an input the built-in doesn't know; | |
| 26 | +- the step's environment has `DIFFINERY_BUILTINS` set to `false`, `0`, `no` or `off` (in the workflow's, the job's or the step's `env:`); | |
| 27 | +- a self-hosted runner was started with `DIFFINERY_DISABLE_BUILTINS=1`. | |
| 28 | + | |
| 29 | +```yaml | |
| 30 | +env: | |
| 31 | + DIFFINERY_BUILTINS: false # every step in this workflow runs the real actions | |
| 32 | +``` | |
| 33 | + | |
| 34 | +The `cache` input of `actions/setup-node`, `actions/setup-python` and `actions/setup-go` uses the same service; see [Dependency caching with setup actions](#dependency-caching-with-setup-actions). | |
| 35 | + | |
| 36 | +Third-party actions that use the `@actions/cache` or `@actions/artifact` toolkit packages, and `docker/build-push-action` with `cache-from: type=gha` and `cache-to: type=gha`, work as well (see [The runtime services](#the-runtime-services)). | |
| 37 | + | |
| 38 | +## Caching dependencies | |
| 39 | + | |
| 40 | +A cache entry is a compressed archive of one or more paths, stored under a key you choose. A typical workflow restores the entry at the start of a job and saves it at the end: | |
| 41 | + | |
| 42 | +```yaml | |
| 43 | +name: CI | |
| 44 | +on: [push, pull_request] | |
| 45 | + | |
| 46 | +jobs: | |
| 47 | + test: | |
| 48 | + runs-on: ubuntu-latest | |
| 49 | + steps: | |
| 50 | + - uses: actions/checkout@v4 | |
| 51 | + - uses: actions/cache@v4 | |
| 52 | + id: npm-cache | |
| 53 | + with: | |
| 54 | + path: ~/.npm | |
| 55 | + key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} | |
| 56 | + restore-keys: | | |
| 57 | + npm-${{ runner.os }}- | |
| 58 | + - run: npm ci | |
| 59 | + - run: npm test | |
| 60 | +``` | |
| 61 | + | |
| 62 | +On the first run there is no entry, so the step logs `Cache not found for input keys: ...` and the job continues. When the job succeeds, the post step **Post actions/cache@v4** archives `~/.npm` and saves it under the key (`Cache saved with key: <key>`). The next run with the same `package-lock.json` restores it (`Cache restored from key: <key>`). | |
| 63 | + | |
| 64 | +### Inputs for actions/cache | |
| 65 | + | |
| 66 | +| Input | Required | Default | Description | | |
| 67 | +| --- | --- | --- | --- | | |
| 68 | +| `path` | Yes | | Files and directories to cache, one per line. Supports globs and `!` exclusions (`! pattern` is read as `!pattern`). Relative paths are relative to the workspace; `~/` expands to the home directory. | | |
| 69 | +| `key` | Yes | | Key to save the entry under and to look up first. At most 512 characters, no commas. | | |
| 70 | +| `restore-keys` | No | | Prefixes to try, one per line, in order, when `key` has no exact match. At most 20. | | |
| 71 | +| `lookup-only` | No | `false` | Check whether an entry exists without downloading it. Logs `Cache found and can be restored from key: <key>`. | | |
| 72 | +| `fail-on-cache-miss` | No | `false` | Fail the step when neither `key` nor any restore key matches. | | |
| 73 | +| `save-always` | No | `false` | Save in the post step even when the job failed or was cancelled. | | |
| 74 | +| `upload-chunk-size`, `enableCrossOsArchive` | No | | Accepted. The built-in uploads in 16 MiB blocks, 8 at a time (8 MiB blocks, 4 at a time, on the 1 GiB `tiny` runner), whatever `upload-chunk-size` says. | | |
| 75 | + | |
| 76 | +`actions/cache/restore` and `actions/cache/save` take the same inputs. If `key` or `path` is missing, the step fails with `Input required and not supplied: key` or `Input required and not supplied: path`. | |
| 77 | + | |
| 78 | +### Outputs for actions/cache | |
| 79 | + | |
| 80 | +| Output | Set by | Value | | |
| 81 | +| --- | --- | --- | | |
| 82 | +| `cache-hit` | `actions/cache`, `actions/cache/restore` | `true` when the restored entry's key equals `key` (ignoring case), `false` when a restore key matched. Not set on a miss. | | |
| 83 | +| `cache-primary-key` | `actions/cache/restore` | The `key` input, always. | | |
| 84 | +| `cache-matched-key` | `actions/cache/restore` | The key of the entry that was restored. Not set on a miss. | | |
| 85 | + | |
| 86 | +Use `cache-hit` to skip work when the exact entry was restored. Compare with `'true'`, because the output is empty on a miss: | |
| 87 | + | |
| 88 | +```yaml | |
| 89 | + - uses: actions/cache@v4 | |
| 90 | + id: deps | |
| 91 | + with: | |
| 92 | + path: node_modules | |
| 93 | + key: modules-${{ hashFiles('package-lock.json') }} | |
| 94 | + - if: steps.deps.outputs.cache-hit != 'true' | |
| 95 | + run: npm ci | |
| 96 | +``` | |
| 97 | + | |
| 98 | +### How keys and restore keys match | |
| 99 | + | |
| 100 | +An entry has a key, a version and a scope. The version is a hash of the `path` lines and the compression, computed the way `@actions/cache` computes it, so an entry only restores for the same paths saved with the same compression. The scope is the ref of the job that saved it. | |
| 101 | + | |
| 102 | +When a restore step runs, Diffinery searches these scopes in order, and stops at the first match: | |
| 103 | + | |
| 104 | +1. The job's own ref, for example `refs/heads/feature-x`, `refs/tags/v1.2.0` or, for a `pull_request` run, `refs/pull/7/merge`. | |
| 105 | +2. For pull request runs, the pull request's base branch. For merge queue runs, the queue's target branch (`merge_group.base_ref`). | |
| 106 | +3. The default branch. | |
| 107 | + | |
| 108 | +In each scope it tries: | |
| 109 | + | |
| 110 | +1. An entry whose key equals `key` exactly. | |
| 111 | +2. The newest entry whose key starts with `key`. | |
| 112 | +3. For each line of `restore-keys`, in order, the newest entry whose key starts with that line. | |
| 113 | + | |
| 114 | +```yaml | |
| 115 | +key: build-${{ github.ref_name }}-${{ hashFiles('**/go.sum') }} | |
| 116 | +restore-keys: | | |
| 117 | + build-${{ github.ref_name }}- | |
| 118 | + build- | |
| 119 | +``` | |
| 120 | + | |
| 121 | +With this configuration, a run on a feature branch first looks for the exact key and the newest `build-` entry saved on that branch, then on the default branch. | |
| 122 | + | |
| 123 | +### Scope | |
| 124 | + | |
| 125 | +A job saves only to its own ref. Branches, tags and pull requests read their own entries and those of the default branch (and pull requests those of their base branch), but never each other's. This is the same isolation GitHub has, so a pull request can't plant a cache entry that a release job on the default branch later restores. | |
| 126 | + | |
| 127 | +Different repositories never share entries, even within one organization. | |
| 128 | + | |
| 129 | +### When the cache is saved | |
| 130 | + | |
| 131 | +`actions/cache` saves in a post step that runs after all other steps of the job: | |
| 132 | + | |
| 133 | +- It runs only if the job succeeded so far. With `save-always: true` it runs even after a failure or cancellation. | |
| 134 | +- It's skipped when the restore found an exact match for `key`: `Cache hit occurred on the primary key <key>, not saving cache.` | |
| 135 | +- It archives whatever exists at `path` at that moment. If none of the paths exist, it logs the warning `Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.` | |
| 136 | + | |
| 137 | +Entries are immutable. If an entry with the same key and version already exists in the job's scope, or another running job is saving it, the save logs `Failed to save: Unable to reserve cache with key <key>, another job may be creating this cache.` and the job continues. To refresh an entry, change its key, for example by including a hash of the lock file. An upload left unfinished by a job that has ended is discarded and the key can be saved again. | |
| 138 | + | |
| 139 | +`actions/cache/save` saves immediately when its step runs, so you can save a cache mid-job or from a step with its own `if:`: | |
| 140 | + | |
| 141 | +```yaml | |
| 142 | + - uses: actions/cache/restore@v4 | |
| 143 | + id: restore | |
| 144 | + with: | |
| 145 | + path: .build-cache | |
| 146 | + key: build-${{ github.sha }} | |
| 147 | + restore-keys: build- | |
| 148 | + - run: make build | |
| 149 | + - uses: actions/cache/save@v4 | |
| 150 | + if: always() | |
| 151 | + with: | |
| 152 | + path: .build-cache | |
| 153 | + key: ${{ steps.restore.outputs.cache-primary-key }} | |
| 154 | +``` | |
| 155 | + | |
| 156 | +### Archive format | |
| 157 | + | |
| 158 | +The built-in creates the same archive as `@actions/cache`: a POSIX tar file of the matched paths, stored relative to the workspace, compressed with zstd when `zstd` is installed (it is on hosted runners) and with gzip otherwise. Restoring extracts it to the same paths relative to the workspace. | |
| 159 | + | |
| 160 | +Restoring is built for speed. The runner downloads the archive in 8 MiB ranges, 16 at a time (4 on the 1 GiB `tiny` runner), and pipes them straight into `tar`, so the archive extracts while it is still arriving. On hosted runners a 216 MB pnpm store restores in about 3 to 5 seconds. Every range must come from the same stored entry; if one doesn't, or a range keeps failing after its retries, the log says `Streaming the cache entry failed (<reason>); downloading it in full and extracting it again.` and the runner downloads the whole archive to a file and extracts it again, which replaces anything the stream left half done. If that fails too, the restore removes what it had extracted and behaves like a miss, so a job never runs with half a cache. Artifact downloads use the same ranged download. | |
| 161 | + | |
| 162 | +Saving is built for speed too. Files of up to 32 MiB go up in one request; larger ones go up in 16 MiB blocks, 8 at a time (8 MiB blocks, 4 at a time, on the `tiny` runner). Diffinery then joins the blocks into one stored object. Objects over 16 MiB are joined as 8 MiB parts, copied by several workers at once, so a 200 MB artifact or cache entry is stored within seconds of its last block. The same applies to uploads from the official actions. | |
| 163 | + | |
| 164 | +- The compression is part of the version, so an entry saved on a runner with zstd doesn't restore on one without it. | |
| 165 | +- A path outside the workspace, such as `~/.npm`, is stored relative to the workspace too. A runner whose workspace sits at a different depth restores it into a different directory. Include `runner.environment` or a label in the key if you mix runner types. | |
| 166 | +- The step log shows the archive size as `Cache Size: ~<n> MB (<bytes> B)` on both save and restore. | |
| 167 | + | |
| 168 | +### Size and eviction | |
| 169 | + | |
| 170 | +| Limit | Value | | |
| 171 | +| --- | --- | | |
| 172 | +| Key | 512 characters, no commas. A longer key or a comma fails the restore step with `Key Validation Error: <key> cannot be larger than 512 characters.` or `Key Validation Error: <key> cannot contain commas.` | | |
| 173 | +| Restore keys | 20 per lookup | | |
| 174 | +| One entry | 10 GB. A larger archive isn't saved: `Cache size of ~<n> MB (<bytes> B) is over the 10GB limit, not saving cache.` | | |
| 175 | +| Repository total | 10 GB of entries, counting uploads in progress | | |
| 176 | +| One upload request | 256 MiB. The built-ins and the official actions upload large archives in smaller blocks, so this only matters for your own clients. | | |
| 177 | + | |
| 178 | +Diffinery deletes entries in two ways: | |
| 179 | + | |
| 180 | +- Entries that no job has restored for 7 days are deleted the next time a job saves a cache in the repository. | |
| 181 | +- When a save needs room above the 10 GB total, the least recently used entries are deleted until it fits. Restoring an entry counts as using it. | |
| 182 | + | |
| 183 | +If uploads still in progress use the whole budget, the save is refused (`uploads in progress use the repository's whole cache budget`) and the job continues with a warning. | |
| 184 | + | |
| 185 | +### Manage caches with the API | |
| 186 | + | |
| 187 | +List, inspect and delete cache entries with the REST API. Reading needs read access to the repository; deleting needs write access. For a job's `GITHUB_TOKEN`, these endpoints need the `actions` permission. | |
| 188 | + | |
| 189 | +List entries: | |
| 190 | + | |
| 191 | +```http | |
| 192 | +GET /api/v3/repos/acme/website/actions/caches?key=npm-&ref=refs/heads/main | |
| 193 | +Authorization: Bearer dfy_... | |
| 194 | +``` | |
| 195 | + | |
| 196 | +```json | |
| 197 | +{ | |
| 198 | + "total_count": 1, | |
| 199 | + "actions_caches": [ | |
| 200 | + { | |
| 201 | + "id": 42, | |
| 202 | + "ref": "refs/heads/main", | |
| 203 | + "key": "npm-Linux-3f1c...", | |
| 204 | + "version": "a1b2c3...", | |
| 205 | + "last_accessed_at": "2026-10-12T09:41:07Z", | |
| 206 | + "created_at": "2026-10-10T08:02:51Z", | |
| 207 | + "size_in_bytes": 48211301 | |
| 208 | + } | |
| 209 | + ] | |
| 210 | +} | |
| 211 | +``` | |
| 212 | + | |
| 213 | +| Query parameter | Meaning | | |
| 214 | +| --- | --- | | |
| 215 | +| `key` | Only entries whose key starts with this | | |
| 216 | +| `ref` | Only entries saved for this ref, for example `refs/heads/main` or `refs/pull/7/merge` | | |
| 217 | +| `sort` | `last_accessed_at` (default), `created_at` or `size_in_bytes` | | |
| 218 | +| `direction` | `desc` (default) or `asc` | | |
| 219 | +| `per_page`, `page` | Paging; `per_page` defaults to 30, at most 100 | | |
| 220 | + | |
| 221 | +Other endpoints: | |
| 222 | + | |
| 223 | +| Request | Result | | |
| 224 | +| --- | --- | | |
| 225 | +| `GET /repos/{owner}/{repo}/actions/cache/usage` | `{"full_name": "acme/website", "active_caches_size_in_bytes": ..., "active_caches_count": ...}` | | |
| 226 | +| `DELETE /repos/{owner}/{repo}/actions/caches?key=<key>&ref=<ref>` | Deletes the entries with exactly this key (in every scope, or only `ref`'s). Returns `{"total_count": n, "actions_caches": [...]}` with the deleted entries. `422 key is required` without `key`; `404 Not Found` when nothing matches. | | |
| 227 | +| `DELETE /repos/{owner}/{repo}/actions/caches/{cache_id}` | Deletes one entry. `204 No Content`, or `404 Not Found`. | | |
| 228 | + | |
| 229 | +```sh | |
| 230 | +diffinery api GET "/repos/acme/website/actions/caches?key=npm-" | |
| 231 | +diffinery api DELETE "/repos/acme/website/actions/caches?key=npm-Linux-3f1c..." | |
| 232 | +``` | |
| 233 | + | |
| 234 | +Deletions are recorded in the [audit log](/docs/security/audit-log) as `actions.cache.delete`. | |
| 235 | + | |
| 236 | +### Errors never fail the job | |
| 237 | + | |
| 238 | +Cache service problems are reported as warnings and the job carries on, as on GitHub: | |
| 239 | + | |
| 240 | +- `Failed to restore: <reason>` on restore. The step behaves like a miss, and nothing the restore extracted before it failed is left behind. | |
| 241 | +- `Failed to save: <reason>` on save. | |
| 242 | +- `The cache service is not available to this job; skipping.` when the job has no runtime services. | |
| 243 | + | |
| 244 | +The exceptions are a key validation error (see the table above) and `fail-on-cache-miss: true`, which fails the step with `Failed to restore cache entry. Exiting as fail-on-cache-miss is set. Input key: <key>`. | |
| 245 | + | |
| 246 | +## Dependency caching with setup actions | |
| 247 | + | |
| 248 | +`actions/setup-node`, `actions/setup-python` and `actions/setup-go` can restore and save a dependency cache for you. They use the same service, scopes and limits as `actions/cache`, and the same keys and paths as the official setup actions, so a cache saved by the built-in restores with the official action and the other way round. The save happens in a post step only when the job succeeds, the exact key wasn't restored, and the cache directory exists. | |
| 249 | + | |
| 250 | +| Action | Input | Cached directory | Files hashed by default | Key on a hosted runner | | |
| 251 | +| --- | --- | --- | --- | --- | | |
| 252 | +| `actions/setup-node` | `cache: npm`, `pnpm` or `yarn` | `npm config get cache`, `pnpm store path`, or the Yarn cache folder | The first of `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock` in the workspace root (npm); `pnpm-lock.yaml` (pnpm); `yarn.lock` (yarn) | `node-cache-Linux-x64-<manager>-<hash>` | | |
| 253 | +| `actions/setup-python` | `cache: pip` | `pip cache dir` | `**/requirements.txt`, or `**/pyproject.toml` when none match | `setup-python-Linux-x64-<os version>-<os name>-python-<version>-pip-<hash>`, with the part before the hash as a restore key | | |
| 254 | +| `actions/setup-python` | `cache: pipenv` | `~/.local/share/virtualenvs` | `**/Pipfile.lock` | `setup-python-Linux-x64-python-<version>-pipenv-<hash>` | | |
| 255 | +| `actions/setup-go` | `cache` (on by default) | `GOMODCACHE` and `GOCACHE` | `go.sum` in the workspace root | `setup-go-Linux-x64-ubuntu24-go-<version>-<hash>` | | |
| 256 | + | |
| 257 | +All three accept `cache-dependency-path` to hash different files, one pattern per line, and set the `cache-hit` output (`true` or `false`). `actions/setup-node` also sets `cache-primary-key`. | |
| 258 | + | |
| 259 | +```yaml | |
| 260 | + - uses: actions/setup-node@v4 | |
| 261 | + with: | |
| 262 | + node-version: 22 | |
| 263 | + cache: pnpm | |
| 264 | + cache-dependency-path: apps/web/pnpm-lock.yaml | |
| 265 | +``` | |
| 266 | + | |
| 267 | +Details to be aware of: | |
| 268 | + | |
| 269 | +- `actions/setup-node@v5` and later turn on npm caching by themselves when `package.json` names npm in `packageManager` or `devEngines.packageManager`, and log `Detected npm as the package manager from package.json's packageManager field. ...`. Set `package-manager-cache: false` to turn it off. | |
| 270 | +- If setup-node's lock file is missing, the step fails with `Dependencies lock file is not found in <workspace>. Supported file patterns: <files>`. If `cache-dependency-path` matches nothing, it fails with `Some specified paths were not resolved, unable to cache dependencies.` | |
| 271 | +- If setup-python's patterns match nothing, the step fails with `No file in <workspace> matched to [<patterns>], make sure you have checked out the target repository`. | |
| 272 | +- `actions/setup-go` doesn't fail without a `go.sum`: it logs the warning `Restore cache failed: Dependencies lock file is not found in <workspace>. Supported file patterns: go.sum` and continues. Set `cache: false` to turn caching off. | |
| 273 | +- On a miss the log says `<manager> cache is not found`, for example `npm cache is not found`. | |
| 274 | +- `cache` values the built-ins don't handle (for example `cache: poetry` in setup-python) run the official action instead. | |
| 275 | + | |
| 276 | +## Artifacts | |
| 277 | + | |
| 278 | +An artifact is a named file, normally a zip archive, that belongs to a workflow run. Jobs in the same run download it with `actions/download-artifact`, other runs can download it with `run-id` and a token, and anyone with read access to the repository can download it from the run page or the API until it expires. | |
| 279 | + | |
| 280 | +### Upload an artifact | |
| 281 | + | |
| 282 | +```yaml | |
| 283 | + - run: npm run build | |
| 284 | + - uses: actions/upload-artifact@v4 | |
| 285 | + id: upload | |
| 286 | + with: | |
| 287 | + name: site | |
| 288 | + path: | | |
| 289 | + dist/ | |
| 290 | + !dist/**/*.map | |
| 291 | + if-no-files-found: error | |
| 292 | + retention-days: 14 | |
| 293 | +``` | |
| 294 | + | |
| 295 | +| Input | Required | Default | Description | | |
| 296 | +| --- | --- | --- | --- | | |
| 297 | +| `name` | No | `artifact` | Artifact name. It can't contain `"`, `:`, `<`, `>`, `\|`, `*`, `?`, `\`, `/`, carriage return or line feed. | | |
| 298 | +| `path` | Yes | | Files, directories or globs to upload, one per line. Lines starting with `!` exclude matches, and lines starting with `#` are ignored. Relative paths are relative to the workspace. | | |
| 299 | +| `if-no-files-found` | No | `warn` | `warn` logs a warning, `error` fails the step, `ignore` logs a plain message. | | |
| 300 | +| `retention-days` | No | 90 | Days to keep the artifact, from 1 to 90. | | |
| 301 | +| `compression-level` | No | `6` | Zip compression level, `0` (none) to `9` (best). Use `0` for files that are already compressed. | | |
| 302 | +| `overwrite` | No | `false` | Replace an artifact with the same name in the run instead of failing. | | |
| 303 | +| `include-hidden-files` | No | `false` | Include files and directories whose names start with `.`. | | |
| 304 | +| `archive` | No | `true` | `false` uploads a single file as it is, without a zip archive. The artifact is then named after the file. | | |
| 305 | + | |
| 306 | +The step logs `With the provided path, there will be <n> file(s) uploaded`, then `Artifact <name> has been successfully uploaded! Final size is <n> bytes. Artifact ID is <id>`. | |
| 307 | + | |
| 308 | +### Outputs of upload-artifact | |
| 309 | + | |
| 310 | +| Output | Value | | |
| 311 | +| --- | --- | | |
| 312 | +| `artifact-id` | The artifact's numeric id | | |
| 313 | +| `artifact-digest` | The SHA-256 digest of the uploaded file, in hex | | |
| 314 | +| `artifact-url` | `https://diffinery.com/acme/website/actions/runs/<run number>/artifacts/<id>`, a link that downloads the artifact | | |
| 315 | + | |
| 316 | +### What goes into the archive | |
| 317 | + | |
| 318 | +The runner collects the matching files, then archives them relative to the deepest directory that contains all of them, as `actions/upload-artifact` does: | |
| 319 | + | |
| 320 | +| `path` | Files matched | Paths inside the artifact | | |
| 321 | +| --- | --- | --- | | |
| 322 | +| `dist/` | `dist/index.html`, `dist/assets/app.js` | `index.html`, `assets/app.js` | | |
| 323 | +| `dist/index.html` | `dist/index.html` | `index.html` | | |
| 324 | +| `dist/` and `reports/junit.xml` | `dist/index.html`, `reports/junit.xml` | `dist/index.html`, `reports/junit.xml` | | |
| 325 | + | |
| 326 | +Only files are uploaded; empty directories are dropped. Hidden files are left out unless `include-hidden-files: true`. The archive is a zip file. | |
| 327 | + | |
| 328 | +With `archive: false`, exactly one file may match; otherwise the step fails with `When 'archive' is set to false, only a single file can be uploaded. Found <n> files to upload.` The file is stored with a content type from its extension, for example `text/html` for `.html`. | |
| 329 | + | |
| 330 | +### Download artifacts in a later job | |
| 331 | + | |
| 332 | +The job must run after the job that uploaded the artifact, so list that job in `needs`: | |
| 333 | + | |
| 334 | +```yaml | |
| 335 | +jobs: | |
| 336 | + build: | |
| 337 | + runs-on: ubuntu-latest | |
| 338 | + steps: | |
| 339 | + - uses: actions/checkout@v4 | |
| 340 | + - run: npm ci && npm run build | |
| 341 | + - uses: actions/upload-artifact@v4 | |
| 342 | + with: | |
| 343 | + name: site | |
| 344 | + path: dist/ | |
| 345 | + | |
| 346 | + deploy: | |
| 347 | + needs: build | |
| 348 | + runs-on: ubuntu-latest | |
| 349 | + steps: | |
| 350 | + - uses: actions/download-artifact@v4 | |
| 351 | + id: get-site | |
| 352 | + with: | |
| 353 | + name: site | |
| 354 | + path: public | |
| 355 | + - run: ls -R ${{ steps.get-site.outputs.download-path }} | |
| 356 | +``` | |
| 357 | + | |
| 358 | +| Input | Default | Description | | |
| 359 | +| --- | --- | --- | | |
| 360 | +| `name` | | Download only the artifact with this name. | | |
| 361 | +| `artifact-ids` | | Download only these artifacts, by id, separated by commas. Can't be combined with `name`. | | |
| 362 | +| `pattern` | | Download the artifacts whose names match this glob, for example `junit-*`. | | |
| 363 | +| `path` | The workspace | Directory to extract into, relative to the workspace. It's created if missing. | | |
| 364 | +| `merge-multiple` | `false` | Extract every selected artifact into `path` itself instead of one directory per artifact. | | |
| 365 | +| `skip-decompress` | `false` | Save the zip file instead of extracting it. | | |
| 366 | +| `digest-mismatch` | `error` for `v8` and later, `warn` before | What to do when a download doesn't match the artifact's digest: `ignore`, `info`, `warn` or `error`. | | |
| 367 | +| `github-token`, `run-id`, `repository` | | Download from another run or repository; see below. | | |
| 368 | + | |
| 369 | +With no `name`, `artifact-ids` or `pattern`, every artifact of the run is downloaded. Where they go: | |
| 370 | + | |
| 371 | +- With `name`, or `merge-multiple: true`, files go straight into `path`. | |
| 372 | +- Otherwise each artifact goes into `<path>/<artifact name>`. From `v5` on, a download that selects exactly one artifact goes straight into `path`. | |
| 373 | + | |
| 374 | +The step sets the output `download-path` to the absolute directory, and logs `Artifact '<name>' downloaded to <path>` for each artifact. | |
| 375 | + | |
| 376 | +### Download from another run or repository | |
| 377 | + | |
| 378 | +Pass `run-id` and a token in `github-token` to read another run's artifacts through the REST API. Add `repository` for a run in another repository: | |
| 379 | + | |
| 380 | +```yaml | |
| 381 | +jobs: | |
| 382 | + report: | |
| 383 | + runs-on: ubuntu-latest | |
| 384 | + permissions: | |
| 385 | + actions: read | |
| 386 | + steps: | |
| 387 | + - uses: actions/download-artifact@v4 | |
| 388 | + with: | |
| 389 | + name: coverage | |
| 390 | + run-id: ${{ github.event.workflow_run.id }} | |
| 391 | + github-token: ${{ secrets.GITHUB_TOKEN }} | |
| 392 | +``` | |
| 393 | + | |
| 394 | +- `run-id` is a run number, such as `github.run_id` or `github.event.workflow_run.id`. It covers every attempt of that run. | |
| 395 | +- The job's `GITHUB_TOKEN` can read another run of its own repository when the job has `actions: read` (or the repository default gives it). It can read artifacts of other **public** repositories on Diffinery, but not of other private ones; use a personal or agent token with read access for those. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions). | |
| 396 | +- `run-id` without `github-token` is ignored, and the current run is read. | |
| 397 | + | |
| 398 | +Without these inputs, `actions/download-artifact` reads artifacts from the job's own run only. Jobs of a [reusable workflow](/docs/actions/reusable-workflows) are part of the caller's run, so the caller and the called workflow can exchange artifacts. A re-run attempt is a separate run for this purpose: its jobs don't see the artifacts of earlier attempts without `run-id`, except those of the jobs a failed-job or single-job re-run kept from an earlier attempt (see [Re-run a run](/docs/actions/overview#re-run-a-run)), which belong to the new attempt too. | |
| 399 | + | |
| 400 | +### Uploading the same name twice | |
| 401 | + | |
| 402 | +Artifact names are unique within a run. Uploading a name that already exists fails: | |
| 403 | + | |
| 404 | +```text | |
| 405 | +Failed to CreateArtifact: an artifact with the name 'site' already exists on the workflow run. Set 'overwrite: true' to replace it. | |
| 406 | +``` | |
| 407 | + | |
| 408 | +Set `overwrite: true` to delete the old artifact and upload the new one. In a matrix, give each job its own name: | |
| 409 | + | |
| 410 | +```yaml | |
| 411 | + strategy: | |
| 412 | + matrix: | |
| 413 | + node: [20, 22] | |
| 414 | + steps: | |
| 415 | + - run: npm test -- --reporter junit > junit.xml | |
| 416 | + - uses: actions/upload-artifact@v4 | |
| 417 | + with: | |
| 418 | + name: junit-node-${{ matrix.node }} | |
| 419 | + path: junit.xml | |
| 420 | +``` | |
| 421 | + | |
| 422 | +A later job can collect them all with `pattern: junit-*` and `merge-multiple: true`. | |
| 423 | + | |
| 424 | +### Retention and expiry | |
| 425 | + | |
| 426 | +Each artifact keeps its `retention-days` from 1 to 90 days, 90 when you don't set one. A value above 90 is lowered with the warning `Retention days cannot be greater than the maximum allowed retention set within the repository. Using 90 instead.` | |
| 427 | + | |
| 428 | +When an artifact expires: | |
| 429 | + | |
| 430 | +- Its content is deleted, and it no longer counts toward the storage quota. | |
| 431 | +- The run page no longer lists it, and download-artifact no longer finds it. | |
| 432 | +- The REST API still lists it, with `"expired": true`, for another 90 days. Downloading it answers `410 Artifact has expired`. | |
| 433 | + | |
| 434 | +### Storage quota | |
| 435 | + | |
| 436 | +By default a repository can store up to 10 GB of artifacts. Live artifacts and uploads in progress count; expired and deleted artifacts don't. An upload beyond the quota fails the step with a message that ends in: | |
| 437 | + | |
| 438 | +```text | |
| 439 | +the repository's artifact storage quota (10737418240 bytes) is used up; delete artifacts or wait for them to expire | |
| 440 | +``` | |
| 441 | + | |
| 442 | +Delete artifacts you no longer need with the API (see below). A job's unfinished uploads are removed when the job ends. An instance's administrators change the quota with `ARTIFACT_QUOTA_BYTES` (see [Configuration](/docs/self-hosting/configuration)). | |
| 443 | + | |
| 444 | +## Download artifacts from the web | |
| 445 | + | |
| 446 | +Open the run at `https://diffinery.com/<owner>/<repo>/actions/runs/<run number>`. When the run has artifacts that haven't expired, an **Artifacts** box lists each one with its size and upload time. Select a name to download it as `<name>.zip`, or as the file itself for an artifact uploaded with `archive: false`. | |
| 447 | + | |
| 448 | +The download link is `https://diffinery.com/<owner>/<repo>/actions/runs/<run>/artifacts/<name or id>`, the same form as the `artifact-url` output. Extract a zip file with: | |
| 449 | + | |
| 450 | +```sh | |
| 451 | +unzip site.zip -d site | |
| 452 | +``` | |
| 453 | + | |
| 454 | +You need read access to the repository. For a public repository, anyone can download. Artifacts uploaded by older Diffinery runner versions download as `<name>.tar.gz`. | |
| 455 | + | |
| 456 | +## Download artifacts with the API | |
| 457 | + | |
| 458 | +The REST API has GitHub's artifact endpoints. Reading needs read access to the repository; deleting needs write access. For a job's `GITHUB_TOKEN`, they need the `actions` permission. | |
| 459 | + | |
| 460 | +| Request | Result | | |
| 461 | +| --- | --- | | |
| 462 | +| `GET /repos/{owner}/{repo}/actions/artifacts` | Every artifact of the repository, newest first, expired ones included. Takes `name`, `per_page` (default 30, at most 100) and `page`. | | |
| 463 | +| `GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts` | The artifacts of one run, expired ones included. `run_id` is the run number (every attempt) or a run's id. Takes `name`, `per_page` and `page`. | | |
| 464 | +| `GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}` | One artifact. | | |
| 465 | +| `GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip` | `302 Found` to a signed download URL valid for 10 minutes. `410 Artifact has expired` for an expired artifact. | | |
| 466 | +| `DELETE /repos/{owner}/{repo}/actions/artifacts/{artifact_id}` | Deletes the artifact. `204 No Content`. Recorded in the [audit log](/docs/security/audit-log) as `actions.artifact.delete`. | | |
| 467 | + | |
| 468 | +Both lists send a `Link` header, so `octokit.paginate` works. Artifacts uploaded by older Diffinery runners are listed, fetched and deleted the same way and kept for 90 days; their download is a gzip-compressed tar file (`<name>.tar.gz`) rather than a zip. Unknown ids answer `404 Not Found`. An artifact looks like this: | |
| 469 | + | |
| 470 | +```json | |
| 471 | +{ | |
| 472 | + "id": 1187, | |
| 473 | + "node_id": "MDg6QXJ0aWZhY3Q1187", | |
| 474 | + "name": "site", | |
| 475 | + "size_in_bytes": 482113, | |
| 476 | + "url": "https://diffinery.com/api/v3/repos/acme/website/actions/artifacts/1187", | |
| 477 | + "archive_download_url": "https://diffinery.com/api/v3/repos/acme/website/actions/artifacts/1187/zip", | |
| 478 | + "expired": false, | |
| 479 | + "digest": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", | |
| 480 | + "created_at": "2026-10-12T09:41:07Z", | |
| 481 | + "updated_at": "2026-10-12T09:41:07Z", | |
| 482 | + "expires_at": "2026-10-26T09:41:07Z", | |
| 483 | + "workflow_run": { | |
| 484 | + "id": 128, | |
| 485 | + "repository_id": "...", | |
| 486 | + "head_repository_id": "...", | |
| 487 | + "head_branch": "main", | |
| 488 | + "head_sha": "9fceb02d0ae598e95dc970b74767f19372d61af8" | |
| 489 | + } | |
| 490 | +} | |
| 491 | +``` | |
| 492 | + | |
| 493 | +`workflow_run.id` is the run number. Download with the CLI's API command or curl; curl follows the redirect with `-L`: | |
| 494 | + | |
| 495 | +```sh | |
| 496 | +curl -fsSL -H "Authorization: Bearer dfy_..." \ | |
| 497 | + https://diffinery.com/api/v3/repos/acme/website/actions/artifacts/1187/zip -o site.zip | |
| 498 | +``` | |
| 499 | + | |
| 500 | +Install the CLI with `curl -fsSL https://diffinery.com/install | bash` and sign in with `diffinery login`, then list a run's artifacts: | |
| 501 | + | |
| 502 | +```sh | |
| 503 | +diffinery api GET /repos/acme/website/actions/runs/128/artifacts | |
| 504 | +``` | |
| 505 | + | |
| 506 | +Artifacts uploaded by older Diffinery runner versions are in these endpoints too, as `.tar.gz` downloads. | |
| 507 | + | |
| 508 | +## The runtime services | |
| 509 | + | |
| 510 | +Diffinery gives each job the same runtime services GitHub does, through these environment variables: | |
| 511 | + | |
| 512 | +| Variable | Value | | |
| 513 | +| --- | --- | | |
| 514 | +| `ACTIONS_RUNTIME_TOKEN` | A token for this job's cache and artifact requests. It works only while the job runs and expires 30 minutes after the job's timeout. | | |
| 515 | +| `ACTIONS_RESULTS_URL` | `https://diffinery.com/` | | |
| 516 | +| `ACTIONS_CACHE_URL`, `ACTIONS_RUNTIME_URL` | `https://diffinery.com/_runtime/` | | |
| 517 | +| `ACTIONS_CACHE_SERVICE_V2` | `true` | | |
| 518 | + | |
| 519 | +As on GitHub, they're given to JavaScript actions, Docker container actions and the built-ins, not to `run:` steps. So `docker buildx build --cache-to type=gha` in a `run:` step finds no cache service, while `docker/build-push-action`, a JavaScript action, runs the build with them. | |
| 520 | + | |
| 521 | +The services speak the protocols the `@actions/cache` and `@actions/artifact` toolkit packages use: cache service v2 and artifact service v4, the older cache REST API, and signed upload and download URLs. A signed download URL is valid for one hour. Requests from a job that has finished fail with `401 the job is not running`. | |
| 522 | + | |
| 523 | +For Docker layer caching you can use `type=gha` through `docker/build-push-action`, which stores the layers as cache entries under the same 10 GB budget, or keep the cache in the [container registry](/docs/packages/container-registry) with `type=registry`, for example `diffinery.com/acme/website:buildcache`. See [Docker in workflows](/docs/actions/docker). | |
| 524 | + | |
| 525 | +## Differences from GitHub | |
| 526 | + | |
| 527 | +| Area | GitHub Actions | Diffinery | | |
| 528 | +| --- | --- | --- | | |
| 529 | +| How the actions run | Downloaded from GitHub | Built into the runner for the listed versions; the real action otherwise | | |
| 530 | +| Cache scope, size and eviction | Per ref with fallbacks, 10 GB, 7-day idle eviction | The same | | |
| 531 | +| Artifact storage | Counted against the account's storage | 10 GB per repository by default, then uploads are refused | | |
| 532 | +| Artifact retention | Set per repository or organization | 1 to 90 days, 90 by default | | |
| 533 | +| Web page for caches | Yes | No; use the API | | |
| 534 | +| `artifact-url` | Points at github.com | Points at Diffinery | | |
| 535 | +| `run-id` in download-artifact | The run's id | The run number (`github.run_id`), which is the same thing on Diffinery | | |
| 536 | + | |
| 537 | +## Troubleshooting | |
| 538 | + | |
| 539 | +| Symptom | Cause and fix | | |
| 540 | +| --- | --- | | |
| 541 | +| `Cache not found for input keys: ...` on every run | The save never happened, or it's in a scope this run can't read. Check the post step's log: the job may have failed (use `save-always: true`), the paths may not exist. An entry saved on a feature branch isn't visible to other branches; save on the default branch for shared entries. | | |
| 542 | +| `Failed to save: Unable to reserve cache with key <key>, another job may be creating this cache.` | An entry with this key already exists for this ref, or another job is saving it. Include something that changes in the key, such as `hashFiles(...)`, and use `restore-keys` to pick up the latest. | | |
| 543 | +| `Key Validation Error: ... cannot contain commas.` | Remove commas from `key` and `restore-keys`. | | |
| 544 | +| `cache-hit` is `false` although files were restored | A restore key matched, not `key`. That's expected. | | |
| 545 | +| A cache saved by one runner doesn't restore on another | The version includes the paths and the compression. Use the same `path` lines, and make sure `zstd` is installed on both or neither. | | |
| 546 | +| Restored files appear in an unexpected directory | Paths are stored relative to the workspace. A runner with a different workspace depth restores into other directories. Add the runner type to the key. | | |
| 547 | +| `The artifact name is not valid: <name>. Contains the following character: ...` | Remove `/`, `\`, `:` and the other characters listed in [Upload an artifact](#upload-an-artifact). | | |
| 548 | +| `No files were found with the provided path: ... No artifacts will be uploaded.` | The paths don't match any files, or only hidden files. Check the paths relative to the workspace, or set `include-hidden-files: true`. | | |
| 549 | +| `Failed to CreateArtifact: an artifact with the name '<name>' already exists on the workflow run. ...` | Set `overwrite: true`, or include matrix values in the name. | | |
| 550 | +| `... artifact storage quota ... is used up; delete artifacts or wait for them to expire` | Delete old artifacts with `DELETE /repos/{owner}/{repo}/actions/artifacts/{artifact_id}`, or use shorter `retention-days`. | | |
| 551 | +| `Unable to download artifact(s): Artifact not found for name: <name>` | No job in this run has uploaded that name yet, or it expired. Add the uploading job to `needs`, and check the name's spelling and case. | | |
| 552 | +| `Inputs 'name' and 'artifact-ids' cannot be used together. Please specify only one.` | Remove one of the two inputs. | | |
| 553 | +| `Digest validation failed for artifact(s): ...` | The downloaded file doesn't match the digest recorded at upload. Download again; set `digest-mismatch: warn` to continue anyway. | | |
| 554 | +| `Listing artifacts of run <n> in <repo> failed: HTTP 403` | The token in `github-token` lacks access. Give the job `permissions: actions: read`, or use a token that can read the other repository. | | |
| 555 | +| `410 Artifact has expired` | The artifact passed its retention period. Its content is gone. | |
addedapps/diffinery/docs/actions/compatibility.md+418−0View file
| @@ -1,0 +1,418 @@ | ||
| 1 | +--- | |
| 2 | +title: Compatibility with GitHub Actions | |
| 3 | +description: What works, what works differently and what isn't supported when you run GitHub Actions workflows on Diffinery, with a checklist for porting workflows. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +Diffinery runs GitHub Actions workflow files with the same syntax, triggers, expressions and actions, so most workflows run without changes. This page lists every place where Diffinery behaves differently, what happens when a workflow uses something that isn't supported, and a checklist for porting workflows from GitHub. | |
| 7 | + | |
| 8 | +## At a glance | |
| 9 | + | |
| 10 | +| Area | Status on Diffinery | Details | | |
| 11 | +| --- | --- | --- | | |
| 12 | +| Workflow syntax | Supported, with a few keys accepted but ignored | [Workflow syntax](#workflow-syntax) | | |
| 13 | +| Triggers | `push`, `pull_request`, `pull_request_target`, `merge_group`, `workflow_dispatch`, `schedule`, `workflow_run`, `release`, `repository_dispatch`, `workflow_call` | [Triggers](#triggers) | | |
| 14 | +| Expressions and contexts | Supported, with small differences in values | [Contexts and environment variables](#contexts-and-environment-variables) | | |
| 15 | +| Hosted runners | Linux x64 only (Ubuntu 24.04) | [Runners](#runners) | | |
| 16 | +| Self-hosted runners | Supported, through `diffinery runner` | [Runners](/docs/actions/runners) | | |
| 17 | +| Matrices, `needs`, outputs, job summaries | Supported | [Jobs and steps](#jobs-and-steps) | | |
| 18 | +| `actions/checkout`, `actions/setup-node`, `actions/setup-python`, `actions/setup-go` | Built into the runner for speed; the real action runs for versions or inputs the built-in doesn't cover | [Built-in actions](#built-in-actions) | | |
| 19 | +| `actions/cache`, `actions/upload-artifact`, `actions/download-artifact` | Built into the runner, sharing storage and formats with the official actions, which also work | [Caching and artifacts](/docs/actions/caching-and-artifacts) | | |
| 20 | +| `actions/github-script` | Supported (the real action runs) | [actions/github-script](#actionsgithub-script) | | |
| 21 | +| Other JavaScript, composite and Docker actions | Supported, downloaded from GitHub, with the cache and artifact runtime services | [Other actions](#other-actions) | | |
| 22 | +| Job containers and service containers | Supported | [Docker in workflows](/docs/actions/docker) | | |
| 23 | +| Reusable workflows | Supported, except a matrix on the calling job | [Reusable workflows](/docs/actions/reusable-workflows) | | |
| 24 | +| Secrets and variables | Repository, organization and environment secrets and variables | [Secrets and variables](/docs/actions/secrets-and-variables) | | |
| 25 | +| Environments | Supported: branch policies, required reviewers (people), wait timers, environment secrets and variables, deployments. | [Secrets, variables and environments](#secrets-variables-and-environments) | | |
| 26 | +| `GITHUB_TOKEN` | Follows `permissions:` per endpoint, as on GitHub; read-only by default in new repositories | [GITHUB_TOKEN and permissions](#github_token-and-permissions) | | |
| 27 | +| OIDC | Supported, with Diffinery as the issuer | [OIDC](/docs/actions/oidc) | | |
| 28 | +| Windows and macOS hosted runners | Not available (Cloudflare Containers run Linux only); use self-hosted runners | [Runners](#runners) | | |
| 29 | + | |
| 30 | +## Workflow files | |
| 31 | + | |
| 32 | +Diffinery reads workflows from `.github/workflows/` and from `.diffinery/workflows/`. Every `.yml` or `.yaml` file directly in either directory is a workflow; files in subdirectories are ignored. Workflows in both directories run, each as its own workflow. Put workflows that should only run on Diffinery in `.diffinery/workflows/`, so a GitHub mirror of the repository doesn't run them. See [Where workflow files live](/docs/actions/overview#where-workflow-files-live). | |
| 33 | + | |
| 34 | +Check a workflow before you push it: | |
| 35 | + | |
| 36 | +```sh | |
| 37 | +diffinery workflow validate .github/workflows/ci.yml | |
| 38 | +``` | |
| 39 | + | |
| 40 | +Errors fail the file; warnings, such as `unknown job key '<key>'` or `unknown event '<name>'`, are printed but the workflow still runs. | |
| 41 | + | |
| 42 | +## Triggers | |
| 43 | + | |
| 44 | +| Event | Status | Differences | | |
| 45 | +| --- | --- | --- | | |
| 46 | +| `push` | Supported | `commits` in the payload lists only the pushed head commit. Path filters pass when a push creates a branch. | | |
| 47 | +| `pull_request` | Supported | Activity types `opened`, `synchronize`, `reopened`, `closed`, `edited`, `ready_for_review` and `converted_to_draft` only; the `edited` payload has no `changes` object. No runs while the pull request conflicts with its base. Runs get the repository's secrets, because pull requests come from branches of the same repository. | | |
| 48 | +| `pull_request_target` | Supported | Same activity types as `pull_request`. Runs also while the pull request conflicts. | | |
| 49 | +| `merge_group` | Supported | `checks_requested`. | | |
| 50 | +| `workflow_dispatch` | Supported | `github.event.inputs` keeps the input types instead of turning them into strings. The dispatch API answers `201 Created` with the run instead of `204 No Content`. | | |
| 51 | +| `schedule` | Supported | Checked every 5 minutes, so a schedule starts within about 5 minutes after it's due. Runs have the actor `diffinery-scheduler`. | | |
| 52 | +| `workflow_run` | Supported | Activity types `requested` and `completed` (not `in_progress`), `workflows` is required, and chains go at most 3 levels deep. | | |
| 53 | +| `release` | Supported | Same activity types. No runs for draft releases, as on GitHub. Without `types`, a new published release starts three runs (`created`, `published`, `released`), as on GitHub. Only `types` filters; `branches`, `tags` and `paths` aren't applied. | | |
| 54 | +| `repository_dispatch` | Supported | `POST /repos/{owner}/{repo}/dispatches` answers `204`. The payload has `action`, `branch`, `client_payload`, `repository` and `sender`. | | |
| 55 | +| `workflow_call` | Supported | See [Reusable workflows](/docs/actions/reusable-workflows). | | |
| 56 | +| Every other GitHub event (`issues`, `issue_comment`, `pull_request_review`, `create`, `delete`, `status`, `deployment`, `deployment_status` and the rest) | Accepted, never fires | The workflow parses, but Diffinery never starts a run for these events. (Webhooks do get `status`, `deployment` and `deployment_status`.) | | |
| 57 | +| A name that isn't a GitHub event | Warning | `unknown event '<name>'`. | | |
| 58 | + | |
| 59 | +Details for each event are in [Events that trigger workflows](/docs/actions/events). | |
| 60 | + | |
| 61 | +## Workflow syntax | |
| 62 | + | |
| 63 | +Every key of the GitHub Actions workflow syntax parses. These keys behave differently: | |
| 64 | + | |
| 65 | +| Key | What happens on Diffinery | | |
| 66 | +| --- | --- | | |
| 67 | +| `jobs.<id>.runs-on` | Supported, including the `matrix` context (`runs-on: ${{ matrix.os }}`), but only Linux labels are hosted. The `group` key is ignored. | | |
| 68 | +| `jobs.<id>.environment` | Supported: the branch policy is enforced, the environment's secrets and variables are added and a deployment is recorded. Required reviewers and wait timers hold the job before it starts, and `url` can't use step outputs. See [environment](/docs/actions/workflow-syntax#environment). | | |
| 69 | +| `jobs.<id>.snapshot` | Accepted and ignored. | | |
| 70 | +| `jobs.<id>.timeout-minutes` | Default and maximum 360. Larger values are capped at 360. | | |
| 71 | +| `jobs.<id>.strategy.matrix` | At most 256 jobs per matrix, and at most 1,000 jobs in a run, matrix legs and the jobs of called workflows included. | | |
| 72 | +| `jobs.<id>.uses` with `strategy.matrix` | Not supported yet. The run fails with `jobs.<id>: a matrix on a job that calls a reusable workflow is not supported yet`. | | |
| 73 | +| `concurrency` (workflow level) | Same as GitHub: one run in progress and at most one waiting per group; a newer run cancels older waiting ones. After 6 hours of waiting a run starts anyway. See [Concurrency](/docs/actions/overview#concurrency). | | |
| 74 | +| `defaults.run.shell`, `jobs.<id>.steps[*].shell` | `cmd` isn't supported. `pwsh` and `powershell` need PowerShell, which hosted runners don't have. | | |
| 75 | + | |
| 76 | +An unknown key gives a warning such as `unknown job key '<key>'` or `unknown step key '<key>'`, and the key is ignored. The full reference is [Workflow syntax](/docs/actions/workflow-syntax). | |
| 77 | + | |
| 78 | +## Runners | |
| 79 | + | |
| 80 | +| `runs-on` | GitHub | Diffinery | | |
| 81 | +| --- | --- | --- | | |
| 82 | +| `ubuntu-latest`, `ubuntu-24.04`, `ubuntu-22.04` | Hosted Ubuntu VM | Hosted Ubuntu 24.04 container, x64. All three labels get the same image. | | |
| 83 | +| `ubuntu-24.04-arm` and other ARM labels | Hosted ARM runner | No hosted ARM runners. An `ubuntu-*` ARM label runs on the same x64 image as `ubuntu-latest`. For ARM, use `[self-hosted, ...]` labels on an ARM machine. | | |
| 84 | +| `windows-*`, `macos-*` | Hosted Windows or macOS VM | Not available: Cloudflare Containers run Linux only. The job fails with `No Diffinery runner matches runs-on: <labels>. Linux (ubuntu-*) runners are hosted; anything else needs runs-on: [self-hosted, ...] and a registered runner.` | | |
| 85 | +| Larger runners and runner groups | Supported | Not available. `group` is ignored. | | |
| 86 | +| `[self-hosted, ...]` | Self-hosted runner | Self-hosted runner started with `diffinery runner`, matched on every label. | | |
| 87 | + | |
| 88 | +What's different about the hosted image: | |
| 89 | + | |
| 90 | +- It's much smaller than GitHub's image. Git, Git LFS, curl, jq, zstd, build-essential, Python 3.12, Node.js 22 with npm, pnpm and yarn, nvm, Docker and the GitHub CLI (`gh`) are preinstalled. `ImageOS` is `ubuntu24`, as on GitHub's `ubuntu-24.04` image. Go, Java, Ruby, Rust, .NET, PowerShell, browsers and cloud CLIs aren't. See [The Ubuntu 24.04 image](/docs/actions/runners#the-ubuntu-2404-image). | |
| 91 | +- Jobs run as `root`. `sudo` is installed and needs no password, as on GitHub, so `sudo` in commands keeps working. | |
| 92 | +- The apt package lists are removed. Run `apt-get update` before `apt-get install`. | |
| 93 | +- Every job starts in a fresh container. Nothing carries over between jobs except caches and artifacts. | |
| 94 | + | |
| 95 | +On self-hosted runners, `runner.os` is always `Linux`, and the setup actions download Linux builds, even on a macOS machine. See [Self-hosted runners](/docs/actions/runners#self-hosted-runners). | |
| 96 | + | |
| 97 | +## Jobs and steps | |
| 98 | + | |
| 99 | +These work the same as on GitHub: `needs` (a job starts as soon as the jobs it needs finish), job outputs, `if` conditions, status check functions (a skipped or failed job further up the `needs` chain skips the jobs below it unless their `if` says otherwise), matrices with `include` and `exclude`, `fail-fast`, `max-parallel`, job and step `continue-on-error`, job-level `concurrency`, `timeout-minutes`, `working-directory`, environment files (`GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STEP_SUMMARY`, `GITHUB_STATE`), workflow commands, job summaries, masking and debug logging with `ACTIONS_STEP_DEBUG` and `ACTIONS_RUNNER_DEBUG`. | |
| 100 | + | |
| 101 | +Differences: | |
| 102 | + | |
| 103 | +| Behaviour | GitHub | Diffinery | | |
| 104 | +| --- | --- | --- | | |
| 105 | +| Default shell for `run` | `bash -e {0}` | `bash --noprofile --norc -eo pipefail {0}`, so a failing command in a pipeline fails the step. | | |
| 106 | +| `::add-matcher::` problem matchers | Turn matching output into annotations | Not supported; the line is printed as is. | | |
| 107 | +| Annotations (`::error file=...`) | Shown on pull request files | Shown in the job log only. | | |
| 108 | +| `::set-env` and `::add-path` | Disabled | Disabled, with the same message. | | |
| 109 | +| `github.run_number` | Counts runs of one workflow | Counts runs of every workflow in the repository. | | |
| 110 | + | |
| 111 | +See [Expressions and contexts](/docs/actions/expressions) and [Workflow syntax](/docs/actions/workflow-syntax) for the details. | |
| 112 | + | |
| 113 | +## Built-in actions | |
| 114 | + | |
| 115 | +For speed, the runner has its own implementation of these actions and runs it instead of downloading the action, when the step uses a version tag in the listed range: | |
| 116 | + | |
| 117 | +| Action | Built-in for | | |
| 118 | +| --- | --- | | |
| 119 | +| `actions/checkout` | `v2` to `v6` | | |
| 120 | +| `actions/setup-node` | `v3` to `v7` | | |
| 121 | +| `actions/setup-python`, `actions/setup-go` | `v4` to `v6` | | |
| 122 | +| `actions/cache`, `actions/cache/restore`, `actions/cache/save` | `v3` to `v6` | | |
| 123 | +| `actions/upload-artifact` | `v4` to `v7` | | |
| 124 | +| `actions/download-artifact` | `v4` to `v8` | | |
| 125 | + | |
| 126 | +The built-ins mirror the official actions' inputs, outputs and storage: a cache or artifact saved by a built-in works with the official action and the other way round. A built-in steps aside, and the real action is downloaded from GitHub and run, when: | |
| 127 | + | |
| 128 | +- the reference isn't `vN`, `vN.N` or `vN.N.N` in the range, for example a commit SHA, a branch or a newer major version; | |
| 129 | +- the step passes an input the built-in doesn't list (see the tables below); | |
| 130 | +- an input has a value the built-in doesn't handle: `ssh-key` for checkout, an `architecture` other than the runner's, a `cache` value the built-in doesn't support, a PyPy or GraalPy version, or `update-environment: false` for setup-python; | |
| 131 | +- the workflow, job or step `env:` sets `DIFFINERY_BUILTINS` to `false`, `0`, `no` or `off`; | |
| 132 | +- a self-hosted runner was started with `DIFFINERY_DISABLE_BUILTINS=1`. | |
| 133 | + | |
| 134 | +```yaml | |
| 135 | +jobs: | |
| 136 | + build: | |
| 137 | + runs-on: ubuntu-latest | |
| 138 | + env: | |
| 139 | + DIFFINERY_BUILTINS: false # this job runs the real actions | |
| 140 | + steps: | |
| 141 | + - uses: actions/checkout@v4 | |
| 142 | +``` | |
| 143 | + | |
| 144 | +Built-in actions run on the runner, not inside a job container. In a job with `container:`, they work on the shared workspace. | |
| 145 | + | |
| 146 | +### actions/checkout | |
| 147 | + | |
| 148 | +Checks out a repository on the same Diffinery instance into the workspace. | |
| 149 | + | |
| 150 | +| Input | Default | Notes | | |
| 151 | +| --- | --- | --- | | |
| 152 | +| `repository` | The workflow's repository | `owner/name` of a repository on Diffinery. Anything else fails with `Invalid repository '<value>'. Expected format {owner}/{repo}.` | | |
| 153 | +| `ref` | The commit that triggered the run, or the other repository's default branch | Branch, tag, full ref or 40-character commit SHA. A short SHA is treated as a branch or tag name. | | |
| 154 | +| `token` | The job's token | The job's token reads the workflow's own repository (with `contents: read`) and any public repository on Diffinery. For another private repository, pass a personal or agent token from a secret. The job's token is only ever sent to Diffinery; a different `github-server-url` needs an explicit `token`. | | |
| 155 | +| `fetch-depth` | `1` | `0` fetches all branches and tags with full history. | | |
| 156 | +| `fetch-tags` | `false` | | | |
| 157 | +| `path` | The workspace | Relative to the workspace. A path outside it fails with `Repository path '<path>' is not under '<workspace>'`. | | |
| 158 | +| `clean` | `true` | Runs `git clean -ffdx` and `git reset --hard HEAD` on an existing checkout. | | |
| 159 | +| `filter` | | A partial clone filter such as `blob:none`, passed to `git fetch`. Diffinery's git server doesn't support partial clone yet and sends everything. | | |
| 160 | +| `sparse-checkout` | | Patterns, one per line. Sets up a sparse checkout and fetches with `filter: blob:none` unless you set `filter`. | | |
| 161 | +| `sparse-checkout-cone-mode` | `true` | `false` uses non-cone patterns. | | |
| 162 | +| `lfs` | `false` | Fetches Git LFS objects for the checked-out commit (and runs `git lfs pull` in submodules). See [Git LFS](/docs/repositories/git-lfs). | | |
| 163 | +| `submodules` | `false` | `true` or `recursive`. Submodules on Diffinery are fetched with the same credentials. A failed submodule update fails the step. | | |
| 164 | +| `persist-credentials` | `true` | Writes the token into the repository's local Git config at the end, so later `git` commands authenticate. A post step removes it. With `false`, nothing is written. | | |
| 165 | +| `show-progress` | `true` | Passes `--progress` to `git fetch` and `git checkout`. | | |
| 166 | +| `set-safe-directory` | `true` | Marks the checkout as a Git safe directory for checkout's own commands. | | |
| 167 | +| `github-server-url` | Diffinery | Check out from another server, for example `https://github.com`, with an explicit `token` for private repositories. | | |
| 168 | +| `ssh-key` | | Not handled by the built-in: the real action runs. `ssh-known-hosts`, `ssh-strict` and `ssh-user` are accepted and ignored. | | |
| 169 | + | |
| 170 | +While fetching, the token reaches git through the environment, so a checkout that fails halfway leaves no credentials in `.git/config`. Credentials a reused workspace still holds from another job are removed first. | |
| 171 | + | |
| 172 | +Outputs: `commit` (the checked-out SHA) and `ref`. | |
| 173 | + | |
| 174 | +As on GitHub, a `pull_request` run checks out the test merge commit (`refs/pull/<number>/merge`) by default, and a `pull_request_target` run checks out the base branch. | |
| 175 | + | |
| 176 | +Errors: | |
| 177 | + | |
| 178 | +- `Failed to fetch ref '<ref>' from <owner>/<repo>. Check that the ref exists and the token has read access.` | |
| 179 | +- `A branch or tag with the name '<ref>' could not be found` (with `fetch-depth: 0`). | |
| 180 | +- `Checked out <sha> but expected <sha>.` | |
| 181 | + | |
| 182 | +### actions/setup-node | |
| 183 | + | |
| 184 | +| Input | Notes | | |
| 185 | +| --- | --- | | |
| 186 | +| `node-version` | A version or range (`22`, `20.x`, `>=20.10`), `lts/*`, `lts/<codename>`, `lts/-1`, `latest`, `current` or `node`. | | |
| 187 | +| `node-version-file` | `package.json` (reads `volta.node`, then `devEngines.runtime.version`, then `engines.node`), `.tool-versions` (`nodejs` or `node`), or a file whose first non-comment line is the version, such as `.nvmrc` or `.node-version`. | | |
| 188 | +| `check-latest` | Download the newest matching version even when the preinstalled one satisfies the request. | | |
| 189 | +| `registry-url`, `scope`, `always-auth` | Writes an `.npmrc` that reads `NODE_AUTH_TOKEN`, and sets `NPM_CONFIG_USERCONFIG`. For `npm.pkg.github.com` the scope defaults to the repository owner. `always-auth` is written for `v4` and earlier, or when you set it. | | |
| 190 | +| `cache`, `cache-dependency-path` | `npm`, `pnpm` or `yarn`. See [Dependency caching with setup actions](/docs/actions/caching-and-artifacts#dependency-caching-with-setup-actions). | | |
| 191 | +| `package-manager-cache` | `v5` and later cache npm automatically when `package.json` names npm as its package manager; `false` turns that off. | | |
| 192 | +| `architecture`, `token` | Accepted. An `architecture` other than the runner's runs the real action. | | |
| 193 | + | |
| 194 | +Hosted runners have Node.js 22.22.0. A request that it satisfies uses it without a download; any other version is downloaded from `nodejs.org` into the tool cache. Without `node-version` or `node-version-file`, the step logs `No node-version specified; using the pre-installed node <version>.` Other inputs, such as `mirror`, run the real action. | |
| 195 | + | |
| 196 | +Outputs: `node-version` (for example `v22.22.0`), `cache-hit` and `cache-primary-key`. | |
| 197 | + | |
| 198 | +### actions/setup-python | |
| 199 | + | |
| 200 | +| Input | Notes | | |
| 201 | +| --- | --- | | |
| 202 | +| `python-version` | One CPython version or range, such as `3.12`, `3.11.9`, `3.x` or `3.14-dev`. PyPy and GraalPy versions run the real action. | | |
| 203 | +| `python-version-file` | `.python-version`, `.tool-versions` (`python`), `pyproject.toml` (`requires-python`), or a file whose first non-comment line is the version. Without either input, `.python-version` in the workspace is used if present. | | |
| 204 | +| `allow-prereleases` | Falls back to a pre-release build when no stable release matches. A version ending in `-dev` does the same. | | |
| 205 | +| `cache`, `cache-dependency-path` | `pip` or `pipenv`. Other values run the real action. See [Dependency caching with setup actions](/docs/actions/caching-and-artifacts#dependency-caching-with-setup-actions). | | |
| 206 | +| `architecture`, `check-latest`, `token`, `update-environment` | Accepted. An `architecture` other than the runner's, or `update-environment: false`, runs the real action. | | |
| 207 | + | |
| 208 | +Hosted runners have Python 3.12. A request that it satisfies uses it. Other versions are installed from the `actions/python-versions` builds that GitHub publishes, and the step sets `pythonLocation`, `Python_ROOT_DIR`, `Python3_ROOT_DIR`, `PKG_CONFIG_PATH` and `LD_LIBRARY_PATH` like the original action. Other inputs, such as `freethreaded`, run the real action. | |
| 209 | + | |
| 210 | +Outputs: `python-version`, `python-path` and `cache-hit`. | |
| 211 | + | |
| 212 | +### actions/setup-go | |
| 213 | + | |
| 214 | +| Input | Notes | | |
| 215 | +| --- | --- | | |
| 216 | +| `go-version` | A version or range, `stable` or `oldstable`. | | |
| 217 | +| `go-version-file` | `go.mod` or `go.work` (`toolchain` line first, then `go`), `.tool-versions` (`golang` or `go`), or a file whose first line is the version. | | |
| 218 | +| `check-latest` | Download the newest matching version. | | |
| 219 | +| `cache`, `cache-dependency-path` | On by default. Without a `go.sum` in the workspace root, the step warns `Restore cache failed: ...` and continues. | | |
| 220 | +| `architecture`, `token` | Accepted. An `architecture` other than the runner's runs the real action. | | |
| 221 | + | |
| 222 | +Go isn't preinstalled on hosted runners, so the step downloads it from `go.dev`. Without a version on a runner that has no Go, it fails with `No go-version specified and Go is not installed on this runner.` | |
| 223 | + | |
| 224 | +Outputs: `go-version` and `cache-hit`. | |
| 225 | + | |
| 226 | +### actions/cache and artifacts | |
| 227 | + | |
| 228 | +`actions/cache`, `actions/cache/restore`, `actions/cache/save`, `actions/upload-artifact` and `actions/download-artifact` support the official actions' inputs and outputs, including `retention-days`, `overwrite`, `compression-level`, `archive`, downloading all artifacts, `pattern`, `merge-multiple`, `artifact-ids`, and `run-id` with `github-token`. Caches are scoped per ref as on GitHub, each repository keeps up to 10 GB of caches, and artifacts are zip files kept for up to 90 days. [Caching and artifacts](/docs/actions/caching-and-artifacts) has the details. | |
| 229 | + | |
| 230 | +### actions/github-script | |
| 231 | + | |
| 232 | +`actions/github-script` isn't a built-in: the real action runs, and its `github` client talks to Diffinery's REST API (`GITHUB_API_URL`) with the job token. What it may do follows the job's `permissions:`: | |
| 233 | + | |
| 234 | +```yaml | |
| 235 | +jobs: | |
| 236 | + label: | |
| 237 | + runs-on: ubuntu-latest | |
| 238 | + permissions: | |
| 239 | + issues: write | |
| 240 | + pull-requests: write | |
| 241 | + steps: | |
| 242 | + - uses: actions/github-script@v7 | |
| 243 | + with: | |
| 244 | + script: | | |
| 245 | + await github.rest.issues.addLabels({ | |
| 246 | + owner: context.repo.owner, | |
| 247 | + repo: context.repo.repo, | |
| 248 | + issue_number: context.issue.number, | |
| 249 | + labels: ["needs-review"], | |
| 250 | + }); | |
| 251 | +``` | |
| 252 | + | |
| 253 | +`github.graphql` fails: Diffinery's [GraphQL API](/docs/reference/graphql-api) answers personal and agent tokens, not a job's token. REST calls to endpoints Diffinery doesn't have get a JSON `404 Not Found`. See [the REST API reference](/docs/reference/rest-api) for the endpoints Diffinery has. | |
| 254 | + | |
| 255 | +## Other actions | |
| 256 | + | |
| 257 | +Any other `uses: owner/repo[/path]@ref` (and a built-in action the built-in doesn't cover) is downloaded from GitHub (`codeload.github.com`, or with `git` from `github.com` when that endpoint can't be reached) when the step runs and executed according to its `action.yml` or `action.yaml`. | |
| 258 | + | |
| 259 | +### JavaScript actions | |
| 260 | + | |
| 261 | +Actions with `runs.using: node12`, `node16`, `node20`, `node24` or any other `node<version>` run with the runner's own Node.js 22, whatever version they ask for. `pre`, `main`, `post`, `pre-if` and `post-if` are supported. | |
| 262 | + | |
| 263 | +One difference: on GitHub, `pre` scripts run at the start of the job, before any step. On Diffinery, an action's `pre` script runs immediately before its `main` script, as part of the same step. Post scripts run at the end of the job in reverse order, as on GitHub. | |
| 264 | + | |
| 265 | +### Composite actions | |
| 266 | + | |
| 267 | +Composite actions (`runs.using: composite`) are supported, including `inputs`, `outputs`, `env`, `if`, `shell`, `working-directory` and nested `uses`. Composite actions nest at most 10 levels; deeper nesting fails with `Composite action nesting is too deep (max 10).` | |
| 268 | + | |
| 269 | +### Docker container actions | |
| 270 | + | |
| 271 | +Actions with `runs.using: docker` are supported, with `runs.image` set to a `Dockerfile` in the action (built on the runner) or a `docker://` image, plus `args`, `entrypoint`, `env`, `pre-entrypoint` and `post-entrypoint`. A step can also run an image directly with `uses: docker://image`. Hosted runners have Docker; self-hosted runners need it installed. See [Docker container actions](/docs/actions/docker#docker-container-actions). | |
| 272 | + | |
| 273 | +### Local actions | |
| 274 | + | |
| 275 | +`uses: ./path/to/action` reads the action from the workspace, so check out the repository first. Otherwise the step fails with `Can't find 'action.yml' or 'action.yaml' under '<dir>'. Did you forget to run actions/checkout before running your local action?` | |
| 276 | + | |
| 277 | +### Actions stored on Diffinery | |
| 278 | + | |
| 279 | +`uses: acme/my-action@v1` always means the repository `acme/my-action` on GitHub, not on Diffinery. Actions in private GitHub repositories can't be used on hosted runners, because the download has no GitHub credentials. A [self-hosted runner](/docs/actions/runners#self-hosted-runners) can authenticate downloads with `DIFFINERY_GITHUB_TOKEN` in its environment. To use an action that lives in a Diffinery repository, check it out into a directory and use it as a local action: | |
| 280 | + | |
| 281 | +```yaml | |
| 282 | + - uses: actions/checkout@v4 | |
| 283 | + with: | |
| 284 | + repository: acme/my-action | |
| 285 | + ref: v1 | |
| 286 | + path: .actions/my-action | |
| 287 | + token: ${{ secrets.ACTIONS_READ_TOKEN }} | |
| 288 | + - uses: ./.actions/my-action | |
| 289 | +``` | |
| 290 | + | |
| 291 | +`ACTIONS_READ_TOKEN` is a personal or agent token with read access to `acme/my-action`, stored as a secret, because the job token can't read other private repositories. If `acme/my-action` is public, leave `token` out: the job token can read public repositories. | |
| 292 | + | |
| 293 | +### Unsupported action runtimes | |
| 294 | + | |
| 295 | +Any other `runs.using` value fails the step with `Unsupported action runtime 'runs.using: <value>' in <action>`. | |
| 296 | + | |
| 297 | +A version or repository that doesn't exist on GitHub fails with `Unable to resolve action '<owner>/<repo>@<ref>', repository or version not found`. A reference without `@ref` fails with `Expected format {owner}/{repo}[/path]@ref. Actual '<value>'`. | |
| 298 | + | |
| 299 | +### Actions that call the GitHub API | |
| 300 | + | |
| 301 | +Actions built on `@actions/github` or Octokit read `GITHUB_API_URL` and `GITHUB_SERVER_URL`, which point at Diffinery, and authenticate with the job token. Diffinery's REST API uses GitHub's paths, JSON shapes, `Link` header pagination and error format, so calls to endpoints Diffinery has work (see [REST API](/docs/reference/rest-api)), within the job's `permissions:` (see [GITHUB_TOKEN and permissions](#github_token-and-permissions)). Calls to the GraphQL API fail, because it doesn't accept a job's token yet. | |
| 302 | + | |
| 303 | +### Actions that use the cache or artifact services | |
| 304 | + | |
| 305 | +Diffinery sets `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_RESULTS_URL`, `ACTIONS_CACHE_URL`, `ACTIONS_RUNTIME_URL` and `ACTIONS_CACHE_SERVICE_V2` for JavaScript actions, Docker container actions and the built-ins (not for `run:` steps, as on GitHub). Actions that use the `@actions/cache` or `@actions/artifact` packages directly, and `docker/build-push-action` with `cache-from: type=gha` and `cache-to: type=gha`, store their caches and artifacts in Diffinery. See [The runtime services](/docs/actions/caching-and-artifacts#the-runtime-services). | |
| 306 | + | |
| 307 | +The official `actions/upload-artifact` and `actions/download-artifact` refuse to run on a server that looks like GitHub Enterprise Server, so the runner shows them `GITHUB_SERVER_URL=https://github.com`. Their API calls still go to Diffinery, and their `artifact-url` output is rewritten to point at Diffinery. | |
| 308 | + | |
| 309 | +## Contexts and environment variables | |
| 310 | + | |
| 311 | +| Value | GitHub | Diffinery | | |
| 312 | +| --- | --- | --- | | |
| 313 | +| `github.server_url`, `GITHUB_SERVER_URL` | `https://github.com` | `https://diffinery.com` | | |
| 314 | +| `github.api_url`, `GITHUB_API_URL` | `https://api.github.com` | `https://diffinery.com/api/v3` | | |
| 315 | +| `github.run_id`, `GITHUB_RUN_ID` | A number, unique per run | A number: the run's number, the same as `github.run_number`. The API accepts it wherever it takes a run id. | | |
| 316 | +| `github.repository_id` | A number | Diffinery's repository ID, a string | | |
| 317 | +| `github.ref_protected` | `true` for protected branches | Always `false` | | |
| 318 | +| `github.event.repository` | GitHub's repository object | The same object, with Diffinery's values; `id` is numeric and `private` and `visibility` reflect the repository | | |
| 319 | +| `github.actor` for scheduled runs | The last person who edited the cron | `diffinery-scheduler` | | |
| 320 | +| `github.token`, `secrets.GITHUB_TOKEN` | The job token | The job token, also available as `secrets.DIFFINERY_TOKEN` | | |
| 321 | +| `github.actor_id`, `github.repository_owner_id`, `github.workflow_sha` | Set | Not set | | |
| 322 | +| `runner.os`, `RUNNER_OS` | `Linux`, `Windows` or `macOS` | Always `Linux`, also on self-hosted macOS machines | | |
| 323 | +| `runner.name`, `RUNNER_NAME` | The runner's name | `diffinery` | | |
| 324 | +| `runner.environment`, `RUNNER_ENVIRONMENT` | `github-hosted` or `self-hosted` | `diffinery-hosted`, also on self-hosted runners | | |
| 325 | +| `DIFFINERY` | Not set | `true` | | |
| 326 | + | |
| 327 | +`CI=true` and `GITHUB_ACTIONS=true` are set as on GitHub, so tools that detect GitHub Actions keep working. To run a step only on one forge, check the server URL: | |
| 328 | + | |
| 329 | +```yaml | |
| 330 | + - if: github.server_url == 'https://diffinery.com' | |
| 331 | + run: echo "Running on Diffinery" | |
| 332 | +``` | |
| 333 | + | |
| 334 | +The complete list is in [Expressions and contexts](/docs/actions/expressions). | |
| 335 | + | |
| 336 | +## GITHUB_TOKEN and permissions | |
| 337 | + | |
| 338 | +Every job gets a token, available as `secrets.GITHUB_TOKEN`, `secrets.DIFFINERY_TOKEN` and `github.token`. As on GitHub: | |
| 339 | + | |
| 340 | +- Its permissions come from the job's `permissions:`, else the workflow's, else the repository's default. `read-all`, `write-all`, `{}` and per-scope maps work as on GitHub, and a map sets every scope it doesn't list to `none`. | |
| 341 | +- Each REST endpoint needs its scope (`contents`, `issues`, `pull-requests`, `statuses`, `checks`, `deployments`, `actions`, `packages` and so on). A request without it gets `403 Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: <scope>: <level>)`. | |
| 342 | +- It never administers the repository, never reads or changes secrets and variables, and never creates or changes workflow files. Branch protection applies to it. | |
| 343 | +- It can open and approve pull requests only when the repository allows it, and its approvals don't count toward required reviews. | |
| 344 | +- It can read any public repository on Diffinery, but no other private repository. | |
| 345 | +- What it does starts no new runs, except `workflow_dispatch`. | |
| 346 | +- It stops working when the job completes. | |
| 347 | + | |
| 348 | +Differences: | |
| 349 | + | |
| 350 | +- New repositories default to read-only tokens (`contents` and `packages` read), as GitHub does for new repositories. Repositories created before this setting existed default to read and write. An admin changes it on the repository's **Settings → Actions** tab or with `PUT /repos/{owner}/{repo}/actions/permissions/workflow`. | |
| 351 | +- A run whose code came from an agent is held to the agent rules, so its token can't push or merge to the default branch or protected branches. | |
| 352 | + | |
| 353 | +[GITHUB_TOKEN permissions](/docs/actions/workflow-permissions) has the full model, the scope each endpoint needs, and how to change the default. | |
| 354 | + | |
| 355 | +## Secrets, variables and environments | |
| 356 | + | |
| 357 | +| Feature | Status | | |
| 358 | +| --- | --- | | |
| 359 | +| Repository secrets and variables | Supported | | |
| 360 | +| Organization secrets | Supported (CLI and API) | | |
| 361 | +| Organization variables | Supported, through the REST API. Every repository in the organization sees them (no `private` or `selected` visibility). See [Organization variables](/docs/actions/secrets-and-variables#organization-variables). | | |
| 362 | +| Environments with deployment branch policies (protected branches only, or branch and tag patterns) | Supported, through the REST API. See [Environments](/docs/actions/environments). | | |
| 363 | +| Environment secrets and variables | Supported, through the REST API. They win over repository and organization values with the same name. Pull request runs never get environment secrets, and runs started by agents get them only from environments limited to protected branches. | | |
| 364 | +| Team reviewers and custom deployment protection rules | Not supported. Required reviewers are people. | | |
| 365 | +| Setting secrets with a sealed-box `encrypted_value` | Not supported yet. Send the plain `value` over HTTPS; it's encrypted at rest. | | |
| 366 | +| Secrets in `pull_request` runs | Provided, because every pull request comes from a branch of the same repository | | |
| 367 | +| Changing secrets and variables with an agent's token | Refused: only people manage secrets and variables | | |
| 368 | +| Secret names starting with `GITHUB_` | Rejected for repository secrets, as on GitHub | | |
| 369 | + | |
| 370 | +See [Secrets and variables](/docs/actions/secrets-and-variables). | |
| 371 | + | |
| 372 | +## Reusable workflows and OIDC | |
| 373 | + | |
| 374 | +Reusable workflows work as on GitHub, with these limits: they must live on the same Diffinery instance, calls nest at most 4 levels deep, one run calls at most 20, and a calling job can't have a matrix yet. See [Differences from GitHub](/docs/actions/reusable-workflows#differences-from-github) in Reusable workflows. | |
| 375 | + | |
| 376 | +OIDC works with `permissions: id-token: write`, and tokens are issued by `https://diffinery.com`. Configure your cloud provider to trust that issuer instead of `https://token.actions.githubusercontent.com`. The claims differ in a few places; see [Differences from GitHub](/docs/actions/oidc#differences-from-github) in OIDC. | |
| 377 | + | |
| 378 | +## Porting checklist | |
| 379 | + | |
| 380 | +Work through this list for each workflow you move from GitHub. Most workflows need only the first two items. | |
| 381 | + | |
| 382 | +1. Run `diffinery workflow validate` on each file and fix any errors. Read the warnings for ignored keys. | |
| 383 | +2. Push and watch the first run with `diffinery run watch` or on the **Actions** tab. | |
| 384 | +3. Replace `runs-on: windows-*` and `macos-*` with Linux, or with `[self-hosted, ...]` labels and a [self-hosted runner](/docs/actions/runners#self-hosted-runners). This includes `matrix.os` values used in `runs-on`. | |
| 385 | +4. Add `apt-get update` before `apt-get install`. | |
| 386 | +5. Install tools the hosted image doesn't have (Go, Java, Ruby, Rust, PowerShell, cloud CLIs) with a setup action or `apt-get`. | |
| 387 | +6. Check what each job does with `GITHUB_TOKEN`. In a new repository the default token only reads, so give jobs that push, tag, comment, label or merge the `permissions:` they need, or have an admin set the repository default to write (see [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions)). Jobs that change workflow files, or that open or approve pull requests without the repository allowing it, need a personal or agent token from a secret. | |
| 388 | +7. Give `actions/checkout` of another private repository a token from a secret. | |
| 389 | +8. Recreate your environments, their branch policies, secrets and variables through the REST API (see [Environments](/docs/actions/environments)), and set required reviewers by Diffinery login (GitHub's numeric ids and teams don't carry over). | |
| 390 | +9. Replace `github.graphql` calls in `actions/github-script` with REST calls. | |
| 391 | +10. Replace `ghcr.io` with your instance's host (`diffinery.com` here) in image names and logins; see [Container registry](/docs/packages/container-registry#migrating-from-ghcrio). | |
| 392 | +11. Replace hard-coded `github.com` and `api.github.com` URLs with `${{ github.server_url }}` and `${{ github.api_url }}`. | |
| 393 | +12. Update OIDC trust policies in your cloud accounts to the Diffinery issuer and subject format. | |
| 394 | +13. Move actions from private GitHub repositories into a Diffinery repository and use them as local actions. | |
| 395 | + | |
| 396 | +[Migrate from GitHub](/docs/migrate-from-github#workflows) covers moving the rest of the repository. | |
| 397 | + | |
| 398 | +## Troubleshooting | |
| 399 | + | |
| 400 | +| Message | Cause and fix | | |
| 401 | +| --- | --- | | |
| 402 | +| `No Diffinery runner matches runs-on: <labels>. ...` | The labels aren't hosted Linux labels and don't include `self-hosted`. Use `ubuntu-latest`, or `[self-hosted, ...]` with a registered runner. For a matrix job, check the value `runs-on` got for that combination. | | |
| 403 | +| `Error evaluating the job for this matrix combination: <message>` | An expression in `runs-on`, `name`, `timeout-minutes`, `continue-on-error` or `concurrency` failed for one matrix combination. | | |
| 404 | +| `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: ...)` | Add the scope to the job's `permissions:`. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions). | | |
| 405 | +| `refusing to allow a GitHub App to create or update workflow ...` | Job tokens never change workflow files. Use a personal or agent token from a secret. | | |
| 406 | +| `GitHub Actions is not permitted to create pull requests. ...` | An admin allows it with `can_approve_pull_request_reviews`, and the job needs `pull-requests: write`. | | |
| 407 | +| `Unable to resolve action '<owner>/<repo>@<ref>', repository or version not found` | The action or version doesn't exist on GitHub, or the repository is private. | | |
| 408 | +| `Can't find 'action.yml' or 'action.yaml' under '<dir>'. ...` | Check out the repository before using a local action, and check the path. | | |
| 409 | +| `Unsupported action runtime 'runs.using: <value>' in <action>` | The action uses a runtime other than `node*`, `composite` or `docker`. | | |
| 410 | +| ``The `cmd` shell is not supported on Linux runners.`` | Use `bash` or `sh`. | | |
| 411 | +| `Failed to fetch ref '<ref>' from <owner>/<repo>. Check that the ref exists and the token has read access.` | Checking out another private repository needs a token from a secret, the job lacks `contents: read`, or the ref doesn't exist. | | |
| 412 | +| `No go-version specified and Go is not installed on this runner.` | Set `go-version` or `go-version-file`. | | |
| 413 | +| `jobs.<id>: a matrix on a job that calls a reusable workflow is not supported yet` | Move the matrix into the called workflow, or write one calling job per value. | | |
| 414 | +| `A matrix may produce at most 256 jobs.` | Split the matrix. | | |
| 415 | +| `A run may have at most 1000 jobs, ...` | Split the workflow, or trim its matrices. | | |
| 416 | +| `Composite action nesting is too deep (max 10).` | Flatten nested composite actions. | | |
| 417 | +| A built-in action behaves differently from the real one | Run the real action: set `DIFFINERY_BUILTINS: false` in the step's `env:`, or pin the action to a commit SHA. | | |
| 418 | +| A job is skipped although its `if:` looks true | A job further up its `needs` chain failed, was cancelled or was skipped, and the `if:` doesn't call a status function. Use `if: always() && ...` or `if: ${{ !cancelled() && !failure() }}`. | |
addedapps/diffinery/docs/actions/deploy-to-cloudflare-workers.md+284−0View file
| @@ -1,0 +1,284 @@ | ||
| 1 | +--- | |
| 2 | +title: Deploy to Cloudflare Workers | |
| 3 | +description: Deploy a Cloudflare Worker from Diffinery on every push to main, with a Preview per pull request, or let Workers Builds build it through a Cloudflare Artifacts mirror. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +Cloudflare's own build service, Workers Builds, connects to GitHub, GitLab, Cursor Origin and Cloudflare Artifacts repositories (as of October 2026), so a Diffinery repository doesn't show up under **Connect to a repository** in the Cloudflare dashboard yet. You don't need it there to deploy automatically. This page shows two ways that work today: | |
| 7 | + | |
| 8 | +| | [Deploy with Diffinery Actions](#deploy-with-diffinery-actions) (recommended) | [Mirror to Cloudflare Artifacts](#mirror-to-cloudflare-artifacts-for-workers-builds) | | |
| 9 | +|---|---|---| | |
| 10 | +| Who builds | A Diffinery Actions job runs `wrangler deploy` | Workers Builds, after Diffinery pushes a copy of the branch to Artifacts | | |
| 11 | +| Result on the pull request | The job is a check on the pull request, and a comment links the Preview | Nothing; build results are in the Cloudflare dashboard | | |
| 12 | +| Previews | One per pull request, removed when it closes | One per branch | | |
| 13 | +| Production branch | Any branch you choose | `main` only | | |
| 14 | +| Cloudflare plan | Any | Workers Paid (Artifacts isn't on the Free plan) | | |
| 15 | +| Cloudflare credentials in Diffinery | An API token that can edit Workers | An API token that can edit Artifacts | | |
| 16 | + | |
| 17 | +Both use the same Wrangler configuration as any other Worker, so a project that already deploys from GitHub Actions or Workers Builds moves over without changes to its code. | |
| 18 | + | |
| 19 | +## Deploy with Diffinery Actions | |
| 20 | + | |
| 21 | +A workflow deploys `main` to production and gives each pull request its own [Worker Preview](https://developers.cloudflare.com/workers/previews/), with the Preview's address commented on the pull request. This is how Diffinery deploys itself. | |
| 22 | + | |
| 23 | +### 1. Create a Cloudflare API token | |
| 24 | + | |
| 25 | +1. In the Cloudflare dashboard, open **Manage account → Account API tokens** and select **Create Token**. | |
| 26 | +2. Use the **Edit Cloudflare Workers** template. | |
| 27 | +3. Under **Account Resources**, include only the account the Worker deploys to. Under **Zone Resources**, include only the zones whose routes the Worker uses (or none, for a `workers.dev` Worker). | |
| 28 | +4. Create the token and copy it. Copy your **Account ID** too (it's on the account's home page, and in `npx wrangler whoami`). | |
| 29 | + | |
| 30 | +If the Worker uses other resources, such as D1, R2 or Queues, add **Edit** for each of them to the token. | |
| 31 | + | |
| 32 | +### 2. Store the token in Diffinery | |
| 33 | + | |
| 34 | +Under **Settings → Secrets and variables** of the repository, add two repository secrets: | |
| 35 | + | |
| 36 | +| Name | Value | | |
| 37 | +|---|---| | |
| 38 | +| `CLOUDFLARE_API_TOKEN` | The token from step 1 | | |
| 39 | +| `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare account ID | | |
| 40 | + | |
| 41 | +Or from a terminal, with the [Diffinery CLI](/docs/actions/secrets-and-variables#secrets-with-the-cli) signed in with the `admin` scope: | |
| 42 | + | |
| 43 | +```sh | |
| 44 | +diffinery secret set CLOUDFLARE_API_TOKEN | |
| 45 | +diffinery secret set CLOUDFLARE_ACCOUNT_ID | |
| 46 | +``` | |
| 47 | + | |
| 48 | +Pull request runs get repository secrets, because every pull request on Diffinery comes from a branch of the same repository, pushed by someone with write access (see [Secrets and variables](/docs/actions/secrets-and-variables)). That is what lets the preview job below deploy a Preview. If you'd rather keep the production token away from pull request runs, see [Keep the production token in an environment](#keep-the-production-token-in-an-environment). | |
| 49 | + | |
| 50 | +### 3. Allow Previews in the Wrangler configuration | |
| 51 | + | |
| 52 | +Worker Previews need Wrangler 4.135.0 or later in the project, and a `previews` block in the Wrangler configuration (it can be empty). Settings in `previews` apply to Previews only, so a Preview can use its own variables and resources: | |
| 53 | + | |
| 54 | +```sh | |
| 55 | +npm i -D wrangler@latest | |
| 56 | +``` | |
| 57 | + | |
| 58 | +```jsonc | |
| 59 | +// wrangler.jsonc | |
| 60 | +{ | |
| 61 | + "name": "my-worker", | |
| 62 | + "main": "src/index.ts", | |
| 63 | + "compatibility_date": "2026-10-01", | |
| 64 | + "vars": { "ENVIRONMENT": "production" }, | |
| 65 | + "previews": { | |
| 66 | + "vars": { "ENVIRONMENT": "preview" } | |
| 67 | + } | |
| 68 | +} | |
| 69 | +``` | |
| 70 | + | |
| 71 | +Cloudflare's [Previews configuration](https://developers.cloudflare.com/workers/previews/configuration/) explains which settings belong in `previews`. Without Previews, leave out the `preview` and `cleanup` jobs below and keep `deploy`. | |
| 72 | + | |
| 73 | +### 4. Add the workflow | |
| 74 | + | |
| 75 | +Commit this as `.github/workflows/cloudflare.yml` (Diffinery reads workflows from `.github/workflows/` and `.diffinery/workflows/`). Replace `npm ci` with your project's install and build commands: | |
| 76 | + | |
| 77 | +```yaml | |
| 78 | +name: Cloudflare | |
| 79 | + | |
| 80 | +on: | |
| 81 | + push: | |
| 82 | + branches: [main] | |
| 83 | + pull_request: | |
| 84 | + types: [opened, synchronize, reopened, closed] | |
| 85 | + | |
| 86 | +permissions: | |
| 87 | + contents: read | |
| 88 | + | |
| 89 | +jobs: | |
| 90 | + deploy: | |
| 91 | + name: Deploy to production | |
| 92 | + if: github.event_name == 'push' | |
| 93 | + runs-on: ubuntu-latest | |
| 94 | + timeout-minutes: 15 | |
| 95 | + concurrency: | |
| 96 | + group: cloudflare-production | |
| 97 | + cancel-in-progress: false | |
| 98 | + steps: | |
| 99 | + - uses: actions/checkout@v4 | |
| 100 | + - uses: actions/setup-node@v4 | |
| 101 | + with: | |
| 102 | + node-version: 22 | |
| 103 | + cache: npm | |
| 104 | + - run: npm ci | |
| 105 | + - run: npx wrangler deploy | |
| 106 | + env: | |
| 107 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 108 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 109 | + | |
| 110 | + preview: | |
| 111 | + name: Preview | |
| 112 | + if: github.event_name == 'pull_request' && github.event.action != 'closed' | |
| 113 | + runs-on: ubuntu-latest | |
| 114 | + timeout-minutes: 15 | |
| 115 | + permissions: | |
| 116 | + contents: read | |
| 117 | + pull-requests: write | |
| 118 | + concurrency: | |
| 119 | + group: cloudflare-preview-${{ github.event.pull_request.number }} | |
| 120 | + cancel-in-progress: true | |
| 121 | + steps: | |
| 122 | + - uses: actions/checkout@v4 | |
| 123 | + - uses: actions/setup-node@v4 | |
| 124 | + with: | |
| 125 | + node-version: 22 | |
| 126 | + cache: npm | |
| 127 | + - run: npm ci | |
| 128 | + - name: Create or update the Preview | |
| 129 | + id: preview | |
| 130 | + run: | | |
| 131 | + set -euo pipefail | |
| 132 | + out=$(npx wrangler preview --name "pr-$PR" --json) | |
| 133 | + url=$(printf '%s' "$out" | jq -er '.preview.urls[0]') | |
| 134 | + echo "url=$url" >> "$GITHUB_OUTPUT" | |
| 135 | + echo "Preview: $url" >> "$GITHUB_STEP_SUMMARY" | |
| 136 | + env: | |
| 137 | + PR: ${{ github.event.pull_request.number }} | |
| 138 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 139 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 140 | + - name: Link the Preview on the pull request | |
| 141 | + run: | | |
| 142 | + set -euo pipefail | |
| 143 | + api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/issues" | |
| 144 | + auth=(-H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.github+json") | |
| 145 | + marker='<!-- cloudflare-preview -->' | |
| 146 | + text=$(printf '%s\n**Cloudflare Preview:** %s (commit %s)' "$marker" "$URL" "${SHA:0:7}") | |
| 147 | + body=$(jq -n --arg b "$text" '{body: $b}') | |
| 148 | + # The job's own earlier comment, if any: one comment per pull request, kept up to date. | |
| 149 | + id=$(curl -fsS "${auth[@]}" "$api/$PR/comments?per_page=100" \ | |
| 150 | + | jq -r --arg m "$marker" '[.[] | select(.user.login == "diffinery-actions[bot]" and (.body | startswith($m)))][0].id // empty') | |
| 151 | + if [ -n "$id" ]; then | |
| 152 | + curl -fsS -X PATCH "${auth[@]}" "$api/comments/$id" -d "$body" > /dev/null | |
| 153 | + else | |
| 154 | + curl -fsS -X POST "${auth[@]}" "$api/$PR/comments" -d "$body" > /dev/null | |
| 155 | + fi | |
| 156 | + env: | |
| 157 | + PR: ${{ github.event.pull_request.number }} | |
| 158 | + URL: ${{ steps.preview.outputs.url }} | |
| 159 | + SHA: ${{ github.event.pull_request.head.sha }} | |
| 160 | + TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| 161 | + | |
| 162 | + cleanup: | |
| 163 | + name: Delete the Preview | |
| 164 | + if: github.event_name == 'pull_request' && github.event.action == 'closed' | |
| 165 | + runs-on: ubuntu-latest | |
| 166 | + timeout-minutes: 5 | |
| 167 | + steps: | |
| 168 | + - uses: actions/checkout@v4 | |
| 169 | + - uses: actions/setup-node@v4 | |
| 170 | + with: | |
| 171 | + node-version: 22 | |
| 172 | + cache: npm | |
| 173 | + - run: npm ci | |
| 174 | + - run: npx wrangler preview delete --name "pr-$PR" --skip-confirmation | |
| 175 | + env: | |
| 176 | + PR: ${{ github.event.pull_request.number }} | |
| 177 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 178 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 179 | +``` | |
| 180 | + | |
| 181 | +What happens: | |
| 182 | + | |
| 183 | +- **Pushes to `main`** run **Deploy to production**, which runs `wrangler deploy`. Deploys take turns in the `cloudflare-production` group, and when several are waiting only the newest runs, so two merges in a row never deploy out of order. | |
| 184 | +- **Opening or updating a pull request** runs **Preview**. It creates or updates the Preview named `pr-<number>` and keeps one comment on the pull request up to date with the Preview's address. The job is a check on the pull request, so a failed build shows there, and you can make it a [required check](/docs/repositories/branch-protection) for merging. A newer push cancels a Preview build that's still running for the same pull request. | |
| 185 | +- **Closing or merging a pull request** runs **Delete the Preview**, which removes it from Cloudflare. | |
| 186 | + | |
| 187 | +The comment step uses the REST API directly, because `gh` on a runner only reads `GH_TOKEN` for github.com. To use `gh pr comment` instead, set `GH_HOST: diffinery.com` and `GH_ENTERPRISE_TOKEN: ${{ secrets.GITHUB_TOKEN }}` on the step. | |
| 188 | + | |
| 189 | +`cloudflare/wrangler-action` works too, if you prefer it to running `npx wrangler` yourself: actions from GitHub run on Diffinery's runners (see [Compatibility](/docs/actions/compatibility)). | |
| 190 | + | |
| 191 | +### Keep the production token in an environment | |
| 192 | + | |
| 193 | +Repository secrets go to every run, including pull request runs, so anyone who can push a branch can run code that reads them. To keep the production deploy token for reviewed code only, put it in an [environment](/docs/actions/environments) instead: | |
| 194 | + | |
| 195 | +1. Create an environment named `production` and add `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` to it as environment secrets, with the REST API as shown in [Environment secrets and variables](/docs/actions/secrets-and-variables#environment-secrets-and-variables). Adding the first secret limits the environment to protected branches, so protect `main` if it isn't already. Optionally add required reviewers under **Settings → Environments**, so each deploy waits for an approval. | |
| 196 | +2. Add `environment: production` to the `deploy` job. | |
| 197 | +3. For Previews, create a second API token and store it as the repository secret `CLOUDFLARE_PREVIEW_API_TOKEN`; use it in the `preview` and `cleanup` jobs. | |
| 198 | + | |
| 199 | +Environment secrets never go to pull request runs, so the production token stays with jobs on protected branches. A Workers API token can't be limited to Previews, so this keeps the two tokens apart and lets you revoke the preview token on its own; it doesn't stop the preview token from deploying. | |
| 200 | + | |
| 201 | +## Mirror to Cloudflare Artifacts for Workers Builds | |
| 202 | + | |
| 203 | +If you'd rather have Workers Builds run your builds, with its build history and logs in the Cloudflare dashboard, keep developing on Diffinery and let a workflow push each branch to a [Cloudflare Artifacts](https://developers.cloudflare.com/artifacts/) repository. Workers Builds can connect to Artifacts, so it builds and deploys `main` and creates Previews for other branches. | |
| 204 | + | |
| 205 | +Know the limits first: Artifacts needs the Workers Paid plan and bills per operation and per GB stored (the first 10,000 operations and 1 GB each month are included); a repository holds at most 1 GB; only `main` can be the production branch; and Workers Builds doesn't report back to Diffinery, so build results stay in the Cloudflare dashboard. | |
| 206 | + | |
| 207 | +### 1. Create the Artifacts repository | |
| 208 | + | |
| 209 | +With Wrangler signed in to your Cloudflare account: | |
| 210 | + | |
| 211 | +```sh | |
| 212 | +npx wrangler artifacts repos create my-worker --namespace default --default-branch main | |
| 213 | +``` | |
| 214 | + | |
| 215 | +### 2. Store a token that can push to it | |
| 216 | + | |
| 217 | +Create an account API token with the **Artifacts Edit** permission, and add it to the Diffinery repository as the secret `CLOUDFLARE_ARTIFACTS_TOKEN`, with your account ID as `CLOUDFLARE_ACCOUNT_ID`. The workflow below uses it to mint a short-lived push token for the one repository on each run, so no long-lived git password is stored anywhere. | |
| 218 | + | |
| 219 | +### 3. Add the mirror workflow | |
| 220 | + | |
| 221 | +```yaml | |
| 222 | +name: Mirror to Cloudflare Artifacts | |
| 223 | + | |
| 224 | +on: | |
| 225 | + push: | |
| 226 | + branches: ['**'] | |
| 227 | + | |
| 228 | +permissions: | |
| 229 | + contents: read | |
| 230 | + | |
| 231 | +concurrency: | |
| 232 | + group: artifacts-mirror-${{ github.ref }} | |
| 233 | + cancel-in-progress: false | |
| 234 | + | |
| 235 | +jobs: | |
| 236 | + mirror: | |
| 237 | + runs-on: ubuntu-latest | |
| 238 | + timeout-minutes: 10 | |
| 239 | + env: | |
| 240 | + NAMESPACE: default | |
| 241 | + REPO: my-worker | |
| 242 | + steps: | |
| 243 | + - uses: actions/checkout@v4 | |
| 244 | + with: | |
| 245 | + fetch-depth: 0 | |
| 246 | + - name: Push to Artifacts | |
| 247 | + run: | | |
| 248 | + set -euo pipefail | |
| 249 | + base="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/artifacts/namespaces/$NAMESPACE" | |
| 250 | + auth=(-H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json") | |
| 251 | + remote=$(curl -fsS "${auth[@]}" "$base/repos/$REPO" | jq -er .result.remote) | |
| 252 | + token=$(curl -fsS -X POST "${auth[@]}" "$base/tokens" \ | |
| 253 | + -d "$(jq -n --arg r "$REPO" '{repo: $r, scope: "write", ttl: 600}')" | jq -er .result.plaintext) | |
| 254 | + echo "::add-mask::$token" | |
| 255 | + git -c http.extraHeader="Authorization: Bearer $token" push --force "$remote" "HEAD:refs/heads/$GITHUB_REF_NAME" | |
| 256 | + env: | |
| 257 | + CF_TOKEN: ${{ secrets.CLOUDFLARE_ARTIFACTS_TOKEN }} | |
| 258 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 259 | +``` | |
| 260 | + | |
| 261 | +Each push to a branch pushes that branch to Artifacts. Pushes to the same branch take turns, and when several are waiting only the newest runs, so Artifacts always ends up with the branch's latest commit. Deleting a branch on Diffinery doesn't delete it in Artifacts (workflows don't run for deletions); remove old branches there with `git push <remote> --delete <branch>` and a token from `npx wrangler artifacts repos issue-token`. The tokens the workflow mints last 10 minutes. Change `NAMESPACE` and `REPO` to match step 1. | |
| 262 | + | |
| 263 | +### 4. Connect Workers Builds | |
| 264 | + | |
| 265 | +In the Cloudflare dashboard, open **Workers & Pages**, select your Worker (or **Create application → Continue with Artifacts** for a new one), then **Settings → Builds → Connect**, and choose the Artifacts namespace and repository. To get Previews for branches, turn on **Builds for Preview branches** under **Settings → Builds**. Cloudflare's [Artifacts integration guide](https://developers.cloudflare.com/workers/ci-cd/builds/git-integration/artifacts-integration/) has the details. | |
| 266 | + | |
| 267 | +From then on, merging to `main` on Diffinery deploys the Worker through Workers Builds. | |
| 268 | + | |
| 269 | +## Troubleshooting | |
| 270 | + | |
| 271 | +| Problem | Fix | | |
| 272 | +|---|---| | |
| 273 | +| `Authentication error [code: 10000]` from Wrangler | The API token is missing a permission, or doesn't include the Worker's account or zone. Edit the token in the Cloudflare dashboard. | | |
| 274 | +| `wrangler preview` is an unknown command | The project's Wrangler is older than 4.135.0. Run `npm i -D wrangler@latest` and commit the lockfile. | | |
| 275 | +| The preview job deploys, but no comment appears | The job needs `pull-requests: write` in its `permissions:`. | | |
| 276 | +| Secrets are empty in the preview job | The secrets are environment secrets, which pull request runs never receive. Store the preview token as a repository secret. | | |
| 277 | +| The mirror push fails with `403` | The Artifacts token is missing **Artifacts Edit**, or `NAMESPACE` and `REPO` don't name an existing repository. | | |
| 278 | + | |
| 279 | +## Related | |
| 280 | + | |
| 281 | +- [Deployments and environments](/docs/actions/environments): approvals, branch rules and deployment history for production. | |
| 282 | +- [Secrets and variables](/docs/actions/secrets-and-variables): which runs receive which secrets. | |
| 283 | +- [Workflow permissions](/docs/actions/workflow-permissions): what `GITHUB_TOKEN` may do. | |
| 284 | +- Cloudflare's [GitHub Actions guide](https://developers.cloudflare.com/workers/ci-cd/external-cicd/github-actions/) and [Previews examples](https://developers.cloudflare.com/workers/previews/examples/), which the workflow above follows. |
addedapps/diffinery/docs/actions/docker.md+392−0View file
| @@ -1,0 +1,392 @@ | ||
| 1 | +--- | |
| 2 | +title: Docker in workflows | |
| 3 | +description: Run jobs in containers, start service containers, use Docker container actions, and build and push images from Diffinery workflows. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +Diffinery workflows support Docker the same way GitHub Actions does: a job can run inside a container image, start service containers such as databases next to it, use actions packaged as Docker images, and run `docker build`, `docker run` and `docker compose` in steps. This page covers each feature, the differences from GitHub, and complete examples. | |
| 7 | + | |
| 8 | +## Where Docker is available | |
| 9 | + | |
| 10 | +| Runner | Docker | | |
| 11 | +|---|---| | |
| 12 | +| Hosted (`runs-on: ubuntu-latest` and other hosted labels) | Always available. The Docker daemon starts the first time the job uses it. | | |
| 13 | +| Self-hosted (`runs-on: [self-hosted, ...]`) | Available when the machine has Docker installed and running, with the `docker` CLI on the runner's `PATH`. | | |
| 14 | + | |
| 15 | +On hosted runners: | |
| 16 | + | |
| 17 | +- The `docker` command starts the daemon on first use and prints `Starting the Docker daemon`. Jobs that never use Docker don't start it. A job that will use Docker (a job `container:`, `services:`, a `docker://` action, or a step whose `uses:` or `run:` names `docker`) gets the daemon started in the background as the job begins, so it is usually ready by the time checkout and setup are done. A docker command that has waited over 2 minutes for another one to start the daemon fails the step with `The Docker daemon did not start:` instead of hanging. | |
| 18 | +- Docker Hub images are pulled through the registry mirror `https://mirror.gcr.io`, which avoids Docker Hub's anonymous pull limits for official images. Images the mirror doesn't have come from Docker Hub directly. | |
| 19 | +- Containers use the DNS servers `1.1.1.1` and `8.8.8.8`. | |
| 20 | +- Bridge networks, service names, port publishing and image builds all work, as on GitHub's hosted runners. | |
| 21 | + | |
| 22 | +See [Runners](/docs/actions/runners) for the hosted image and for setting up Docker on a self-hosted runner. | |
| 23 | + | |
| 24 | +## Job containers | |
| 25 | + | |
| 26 | +`jobs.<id>.container` runs the job's steps inside a container instead of directly on the runner. | |
| 27 | + | |
| 28 | +```yaml | |
| 29 | +jobs: | |
| 30 | + test: | |
| 31 | + runs-on: ubuntu-latest | |
| 32 | + container: | |
| 33 | + image: node:22-bookworm | |
| 34 | + env: | |
| 35 | + NODE_ENV: test | |
| 36 | + options: --cpus 1 | |
| 37 | + steps: | |
| 38 | + - uses: actions/checkout@v4 | |
| 39 | + - run: node --version && npm ci && npm test | |
| 40 | +``` | |
| 41 | + | |
| 42 | +`container` is either an image name (`container: node:22`) or a mapping: | |
| 43 | + | |
| 44 | +| Key | Description | | |
| 45 | +|---|---| | |
| 46 | +| `image` | Image to run. Expressions are allowed. If it evaluates to an empty string, the job runs without a container. | | |
| 47 | +| `credentials.username`, `credentials.password` | Registry login for a private image. Both are required when either is set ("Registry credentials for IMAGE need both a username and a password."). The password is masked in logs. | | |
| 48 | +| `env` | Environment variables for the container. | | |
| 49 | +| `ports` | Ports to publish, as for `docker run -p`. | | |
| 50 | +| `volumes` | Volumes to mount, as for `docker run -v`. | | |
| 51 | +| `options` | Extra `docker run` options, as one string. `--network` is not allowed. | | |
| 52 | + | |
| 53 | +How it works: | |
| 54 | + | |
| 55 | +1. In the "Initialize containers" section of the job log, Diffinery creates a bridge network for the job, starts any service containers, pulls the image and starts the job container on that network. | |
| 56 | +2. The container's entrypoint is replaced with `tail -f /dev/null` to keep it running, so **the image needs a `tail` command** (almost every Linux image has one). If it exits right away, the job fails with "The job container (IMAGE) exited right after starting. The image needs a 'tail' command to stay up." | |
| 57 | +3. The runner's work directory is mounted into the container at the same path, so `GITHUB_WORKSPACE`, `$GITHUB_OUTPUT`, `$GITHUB_ENV` and the other file commands work unchanged. `HOME` is `/github/home` inside the container. | |
| 58 | +4. Each `run:` step is executed with `docker exec` in the container, with the step's environment. The default shell is `bash` if the image has it, otherwise `sh`. `PATH` comes from the image unless the workflow sets `PATH`. | |
| 59 | +5. JavaScript actions run inside the container with the runner's own Node.js binary, mounted at `/__e/node`. That binary needs glibc, so JavaScript actions don't work in musl-based images such as Alpine (the same limitation GitHub has). | |
| 60 | +6. The built-in actions (`actions/checkout`, `actions/cache`, `actions/upload-artifact`, `actions/download-artifact`, `actions/setup-node`, `actions/setup-python`, `actions/setup-go`) run on the runner itself and work on the shared workspace. When the runner uses the real action instead (for a version or input the built-in doesn't cover, see [Compatibility with GitHub Actions](/docs/actions/compatibility#built-in-actions)), it runs like any other JavaScript action, inside the container. | |
| 61 | +7. The Docker socket is mounted into the job container when the runner has one at `/var/run/docker.sock`, so a step in the container can use Docker if the image includes the `docker` CLI. | |
| 62 | +8. When the job ends, every container the job started and its network are removed. | |
| 63 | + | |
| 64 | +The `job.container` context has `id` and `network`. | |
| 65 | + | |
| 66 | +## Service containers | |
| 67 | + | |
| 68 | +`jobs.<id>.services` starts containers next to the job, for databases, caches and other dependencies your tests need. Each key is the service's name. | |
| 69 | + | |
| 70 | +```yaml | |
| 71 | +services: | |
| 72 | + postgres: | |
| 73 | + image: postgres:16 | |
| 74 | + env: | |
| 75 | + POSTGRES_PASSWORD: postgres | |
| 76 | + ports: ["5432:5432"] | |
| 77 | + options: --health-cmd "pg_isready -U postgres" --health-interval 2s --health-retries 30 | |
| 78 | +``` | |
| 79 | + | |
| 80 | +Each service takes the same keys as a job container (`image`, `credentials`, `env`, `ports`, `volumes`, `options`). A service whose `image` evaluates to an empty string is skipped, as on GitHub, which lets you turn a service on or off with an expression. | |
| 81 | + | |
| 82 | +### Reaching a service | |
| 83 | + | |
| 84 | +How a step reaches a service depends on where the step runs: | |
| 85 | + | |
| 86 | +| The job... | Reach the service at | Notes | | |
| 87 | +|---|---|---| | |
| 88 | +| Has a `container:` | `<service name>:<container port>`, for example `postgres:5432` | Every container of the job joins one network, and each service is registered under its key as a network alias. No `ports` needed. | | |
| 89 | +| Has no `container:` (steps run on the runner) | `localhost:<published port>` | Publish the port with `ports`. `"5432:5432"` publishes on the same port; `"5432"` picks a free host port, which you read from `job.services.<name>.ports['5432']`. | | |
| 90 | + | |
| 91 | +```yaml | |
| 92 | +- run: redis-cli -p ${{ job.services.redis.ports['6379'] }} ping | |
| 93 | +``` | |
| 94 | + | |
| 95 | +The `job.services.<name>` context has `id`, `network` and `ports` (a map from container port to host port). The job log lists the mappings, for example `Service redis is running (6379 -> localhost:32768)`. | |
| 96 | + | |
| 97 | +### Health checks | |
| 98 | + | |
| 99 | +If a service image (or its `options`, through `--health-cmd`) defines a Docker health check, Diffinery waits for the service to become healthy before running the first step, checking with increasing intervals for up to 15 minutes. Services without a health check are considered ready as soon as they start. The job fails when one of these happens (for the first two, the log shows the last 50 lines of the service's output): | |
| 100 | + | |
| 101 | +| Message | Meaning | | |
| 102 | +|---|---| | |
| 103 | +| `Service container NAME failed its health check.` | Docker marked the container unhealthy. | | |
| 104 | +| `Service container NAME exited before it became healthy.` | The container stopped, for example because of a bad environment variable. | | |
| 105 | +| `Service container NAME did not become healthy in time (status: starting).` | It was still starting after 15 minutes. | | |
| 106 | + | |
| 107 | +Add a health check to every service your tests connect to straight away; without one, the first step can start before the database accepts connections. | |
| 108 | + | |
| 109 | +### The --network option | |
| 110 | + | |
| 111 | +Every container of a job shares one network, so `--network` (or `--net`) in `options` is rejected: | |
| 112 | + | |
| 113 | +```text | |
| 114 | +services.cache: the --network option is not supported; every container of the job shares one network. | |
| 115 | +``` | |
| 116 | + | |
| 117 | +## Docker container actions | |
| 118 | + | |
| 119 | +Two kinds of steps run an action in its own container: | |
| 120 | + | |
| 121 | +- `uses: docker://IMAGE` runs a public or mirrored image directly. | |
| 122 | +- An action whose `action.yml` has `runs.using: docker`, with `runs.image` set to a `Dockerfile` in the action (built for the job) or to `docker://IMAGE`. An action directory with a `Dockerfile` and no `action.yml` is treated the same way. | |
| 123 | + | |
| 124 | +```yaml | |
| 125 | +steps: | |
| 126 | + - uses: actions/checkout@v4 | |
| 127 | + # An image straight from a registry; `args` and `entrypoint` drive the container. | |
| 128 | + - uses: docker://alpine:3.20 | |
| 129 | + with: | |
| 130 | + entrypoint: /bin/sh | |
| 131 | + args: -c "ls -la /github/workspace" | |
| 132 | + # A Docker action in this repository (./.github/actions/lint/action.yml + Dockerfile). | |
| 133 | + - uses: ./.github/actions/lint | |
| 134 | + with: | |
| 135 | + strict: true | |
| 136 | +``` | |
| 137 | + | |
| 138 | +```yaml | |
| 139 | +# .github/actions/lint/action.yml | |
| 140 | +name: Lint | |
| 141 | +inputs: | |
| 142 | + strict: | |
| 143 | + default: "false" | |
| 144 | +runs: | |
| 145 | + using: docker | |
| 146 | + image: Dockerfile | |
| 147 | + args: | |
| 148 | + - --strict=${{ inputs.strict }} | |
| 149 | +``` | |
| 150 | + | |
| 151 | +Behaviour, matching GitHub's: | |
| 152 | + | |
| 153 | +- For `docker://` steps, `with.args` is split like a shell command line into the container's arguments and `with.entrypoint` replaces its entrypoint. Any other `with` keys become `INPUT_<NAME>` environment variables. | |
| 154 | +- For `runs.using: docker` actions, inputs become `INPUT_<NAME>` variables, and `runs.args`, `runs.env`, `runs.entrypoint`, `runs.pre-entrypoint`, `runs.post-entrypoint`, `runs.pre-if` and `runs.post-if` are honoured. | |
| 155 | +- A `Dockerfile` action is built with `docker build` before it runs. A build failure fails the step with "Building the action's Dockerfile failed (exit code N)." | |
| 156 | +- The action container runs on the job's network with the workspace mounted at `/github/workspace` (the working directory and `GITHUB_WORKSPACE`), `HOME` at `/github/home`, and the runner's temp directory mounted so `$GITHUB_OUTPUT` and the other file commands work. The Docker socket is mounted when the runner has one. | |
| 157 | +- A non-zero exit fails the step with "Docker run failed with exit code N". | |
| 158 | + | |
| 159 | +Remote Docker actions (`uses: owner/repo@ref`) are downloaded from github.com, like other remote actions. | |
| 160 | + | |
| 161 | +## docker build, run and compose in steps | |
| 162 | + | |
| 163 | +On hosted runners the full Docker CLI is installed with the Buildx and Compose plugins, so ordinary commands work in `run:` steps: | |
| 164 | + | |
| 165 | +```yaml | |
| 166 | +jobs: | |
| 167 | + integration: | |
| 168 | + runs-on: ubuntu-latest | |
| 169 | + steps: | |
| 170 | + - uses: actions/checkout@v4 | |
| 171 | + - run: docker build -t website:test . | |
| 172 | + - run: docker run --rm website:test npm test | |
| 173 | + - run: docker compose -f compose.test.yml up -d --wait | |
| 174 | + - run: ./scripts/integration-tests.sh | |
| 175 | + - if: always() | |
| 176 | + run: docker compose -f compose.test.yml logs && docker compose -f compose.test.yml down -v | |
| 177 | +``` | |
| 178 | + | |
| 179 | +`docker/setup-buildx-action`, `docker/login-action`, `docker/metadata-action` and `docker/build-push-action` also work. On hosted runners, `docker buildx create` with the `docker-container` driver (what `docker/setup-buildx-action` runs) starts BuildKit on the host network and registers it as a remote builder, because the regular driver can't run inside a Cloudflare Container. You don't need to change your workflow for this. | |
| 180 | + | |
| 181 | +BuildKit's `type=gha` cache works with `docker/build-push-action`. The action runs with the job's cache service credentials and hands them to BuildKit, and the layers are stored in the repository's Actions cache, with the same scope, size limit and eviction as `actions/cache`. See [Caching and artifacts](/docs/actions/caching-and-artifacts). | |
| 182 | + | |
| 183 | +```yaml | |
| 184 | +- uses: docker/setup-buildx-action@v3 | |
| 185 | +- uses: docker/build-push-action@v6 | |
| 186 | + with: | |
| 187 | + context: . | |
| 188 | + push: false | |
| 189 | + cache-from: type=gha | |
| 190 | + cache-to: type=gha,mode=max | |
| 191 | +``` | |
| 192 | + | |
| 193 | +> **Note:** The cache service credentials (`ACTIONS_RUNTIME_TOKEN` and `ACTIONS_RESULTS_URL`) are given only to JavaScript actions, Docker container actions and the built-in actions, never to `run:` steps. `docker buildx build --cache-to type=gha` in a `run:` step has no credentials, so use `docker/build-push-action` for the gha cache. | |
| 194 | + | |
| 195 | +## Push images to the Diffinery registry | |
| 196 | + | |
| 197 | +Diffinery hosts a container registry at the instance's own address (`diffinery.com` here), in place of ghcr.io. A workflow signs in with its own `GITHUB_TOKEN`; the job's `packages` permission decides what the token can do: | |
| 198 | + | |
| 199 | +| `permissions` in the workflow or job | `GITHUB_TOKEN` can | | |
| 200 | +|---|---| | |
| 201 | +| No `permissions` block | What the repository default allows: pull for repositories with the `read` default (the default for new repositories), push and pull for those with `write` | | |
| 202 | +| `write-all`, or `packages: write` | Push and pull | | |
| 203 | +| `packages: read`, or `read-all` | Pull | | |
| 204 | +| A mapping without `packages` | Nothing | | |
| 205 | + | |
| 206 | +A job's own `permissions:` replaces the workflow's. To push from a repository with the `read` default, add `packages: write` to the job or the workflow. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions). | |
| 207 | + | |
| 208 | +With write, the token pushes only to packages linked to the workflow's own repository, and creates new packages under the repository's owner (linked to the repository). Other packages of the same owner, including ones that aren't linked to any repository, are pull-only for it. | |
| 209 | + | |
| 210 | +Image names are `diffinery.com/<owner>/<name>` and must be lowercase. `github.repository` keeps the owner's and repository's capitals, so lowercase it in a shell step with `${GITHUB_REPOSITORY,,}`. | |
| 211 | + | |
| 212 | +On hosted runners, image layers of any size push, because the Docker daemon and BuildKit builders reach the registry through a relay that splits large uploads. Self-hosted runners don't have the relay, so from them (as from a laptop or a server) a layer larger than 100 MB fails to push. | |
| 213 | + | |
| 214 | +The registry, its permissions and its API are documented in [Container registry](/docs/packages/container-registry). | |
| 215 | + | |
| 216 | +## Examples | |
| 217 | + | |
| 218 | +### Postgres service for tests | |
| 219 | + | |
| 220 | +Steps run on the runner and reach Postgres on `localhost`: | |
| 221 | + | |
| 222 | +```yaml | |
| 223 | +name: Test | |
| 224 | +on: [push, pull_request] | |
| 225 | + | |
| 226 | +jobs: | |
| 227 | + test: | |
| 228 | + runs-on: ubuntu-latest | |
| 229 | + services: | |
| 230 | + postgres: | |
| 231 | + image: postgres:16 | |
| 232 | + env: | |
| 233 | + POSTGRES_USER: postgres | |
| 234 | + POSTGRES_PASSWORD: postgres | |
| 235 | + POSTGRES_DB: website_test | |
| 236 | + ports: ["5432:5432"] | |
| 237 | + options: >- | |
| 238 | + --health-cmd "pg_isready -U postgres" | |
| 239 | + --health-interval 2s | |
| 240 | + --health-timeout 5s | |
| 241 | + --health-retries 30 | |
| 242 | + env: | |
| 243 | + DATABASE_URL: postgres://postgres:postgres@localhost:5432/website_test | |
| 244 | + steps: | |
| 245 | + - uses: actions/checkout@v4 | |
| 246 | + - uses: actions/setup-node@v4 | |
| 247 | + with: | |
| 248 | + node-version: 22 | |
| 249 | + cache: npm | |
| 250 | + - run: npm ci | |
| 251 | + - run: npm run db:migrate | |
| 252 | + - run: npm test | |
| 253 | +``` | |
| 254 | + | |
| 255 | +The same job in a container reaches the service by name instead: | |
| 256 | + | |
| 257 | +```yaml | |
| 258 | +jobs: | |
| 259 | + test: | |
| 260 | + runs-on: ubuntu-latest | |
| 261 | + container: node:22-bookworm | |
| 262 | + services: | |
| 263 | + postgres: | |
| 264 | + image: postgres:16 | |
| 265 | + env: | |
| 266 | + POSTGRES_PASSWORD: postgres | |
| 267 | + options: --health-cmd "pg_isready -U postgres" --health-interval 2s --health-retries 30 | |
| 268 | + env: | |
| 269 | + DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres | |
| 270 | + steps: | |
| 271 | + - uses: actions/checkout@v4 | |
| 272 | + - run: npm ci && npm test | |
| 273 | +``` | |
| 274 | + | |
| 275 | +### Redis with a random host port | |
| 276 | + | |
| 277 | +```yaml | |
| 278 | +jobs: | |
| 279 | + cache-tests: | |
| 280 | + runs-on: ubuntu-latest | |
| 281 | + services: | |
| 282 | + redis: | |
| 283 | + image: redis:7-alpine | |
| 284 | + ports: ["6379"] | |
| 285 | + options: --health-cmd "redis-cli ping" --health-interval 1s --health-retries 30 | |
| 286 | + steps: | |
| 287 | + - uses: actions/checkout@v4 | |
| 288 | + - run: npm ci && npm test | |
| 289 | + env: | |
| 290 | + REDIS_URL: redis://localhost:${{ job.services.redis.ports['6379'] }} | |
| 291 | +``` | |
| 292 | + | |
| 293 | +### Build and push an image on every push to main | |
| 294 | + | |
| 295 | +With plain Docker commands: | |
| 296 | + | |
| 297 | +```yaml | |
| 298 | +name: Image | |
| 299 | +on: | |
| 300 | + push: | |
| 301 | + branches: [main] | |
| 302 | + | |
| 303 | +jobs: | |
| 304 | + image: | |
| 305 | + runs-on: ubuntu-latest | |
| 306 | + permissions: | |
| 307 | + contents: read | |
| 308 | + packages: write | |
| 309 | + steps: | |
| 310 | + - uses: actions/checkout@v4 | |
| 311 | + - name: Sign in to the Diffinery registry | |
| 312 | + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login diffinery.com -u "${{ github.actor }}" --password-stdin | |
| 313 | + - name: Build and push | |
| 314 | + run: | | |
| 315 | + image="diffinery.com/${GITHUB_REPOSITORY,,}" | |
| 316 | + docker build -t "$image:${{ github.sha }}" -t "$image:latest" . | |
| 317 | + docker push "$image:${{ github.sha }}" | |
| 318 | + docker push "$image:latest" | |
| 319 | +``` | |
| 320 | + | |
| 321 | +With the `docker/*` actions most ghcr.io workflows use: | |
| 322 | + | |
| 323 | +```yaml | |
| 324 | +jobs: | |
| 325 | + image: | |
| 326 | + runs-on: ubuntu-latest | |
| 327 | + permissions: | |
| 328 | + contents: read | |
| 329 | + packages: write | |
| 330 | + steps: | |
| 331 | + - uses: actions/checkout@v4 | |
| 332 | + - run: echo "IMAGE=diffinery.com/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" | |
| 333 | + - uses: docker/setup-buildx-action@v3 | |
| 334 | + - uses: docker/login-action@v3 | |
| 335 | + with: | |
| 336 | + registry: diffinery.com | |
| 337 | + username: ${{ github.actor }} | |
| 338 | + password: ${{ secrets.GITHUB_TOKEN }} | |
| 339 | + - uses: docker/build-push-action@v6 | |
| 340 | + with: | |
| 341 | + context: . | |
| 342 | + push: true | |
| 343 | + tags: ${{ env.IMAGE }}:${{ github.sha }} | |
| 344 | +``` | |
| 345 | + | |
| 346 | +A later job (or another workflow of the same owner) can use the image as its job container: | |
| 347 | + | |
| 348 | +```yaml | |
| 349 | + smoke: | |
| 350 | + needs: image | |
| 351 | + runs-on: ubuntu-latest | |
| 352 | + permissions: | |
| 353 | + packages: read | |
| 354 | + container: | |
| 355 | + image: diffinery.com/acme/website:${{ github.sha }} | |
| 356 | + credentials: | |
| 357 | + username: ${{ github.actor }} | |
| 358 | + password: ${{ secrets.GITHUB_TOKEN }} | |
| 359 | + steps: | |
| 360 | + - run: /app/healthcheck | |
| 361 | +``` | |
| 362 | + | |
| 363 | +A deploy job can then hand the image to your platform, using credentials stored as [environment](/docs/actions/environments) secrets so that only allowed branches reach them. The job records a deployment of the commit to `production`: | |
| 364 | + | |
| 365 | +```yaml | |
| 366 | + deploy: | |
| 367 | + needs: smoke | |
| 368 | + runs-on: ubuntu-latest | |
| 369 | + environment: | |
| 370 | + name: production | |
| 371 | + url: https://www.example.com | |
| 372 | + steps: | |
| 373 | + - name: Tell the platform to roll out the new image | |
| 374 | + env: | |
| 375 | + DEPLOY_HOOK_URL: ${{ secrets.DEPLOY_HOOK_URL }} | |
| 376 | + run: curl -fsS -X POST "$DEPLOY_HOOK_URL" -d "image=diffinery.com/acme/website:${{ github.sha }}" | |
| 377 | +``` | |
| 378 | + | |
| 379 | +## Troubleshooting | |
| 380 | + | |
| 381 | +| Message | Fix | | |
| 382 | +|---|---| | |
| 383 | +| `... need Docker, and this runner has no docker CLI.` | The self-hosted runner has no `docker` on its `PATH`. Install Docker, or run the job on a hosted runner. | | |
| 384 | +| `... need Docker, and the Docker daemon on this runner is not reachable: ...` | Start the Docker daemon on the self-hosted machine, and make sure the runner's user may use it. | | |
| 385 | +| `The Docker daemon did not start:` | A hosted runner couldn't start Docker. Re-run the job; if it keeps failing, report the log lines that follow. | | |
| 386 | +| `Could not pull IMAGE (docker pull exited with N).` | The image name or tag is wrong, or it's private and needs `credentials`. | | |
| 387 | +| `The job container (IMAGE) exited right after starting.` | The image has no `tail` command, or its startup fails. Use an image with a shell and coreutils. | | |
| 388 | +| `Service container NAME failed its health check.` | Check the service log printed above the error, usually a missing environment variable such as `POSTGRES_PASSWORD`. | | |
| 389 | +| `the --network option is not supported` | Remove `--network` from `options`. Services are already on the job's network. | | |
| 390 | +| A JavaScript action fails inside an Alpine job container | The runner's Node.js needs glibc. Use a Debian or Ubuntu based image, or run the action outside the container. | | |
| 391 | +| A registry error ending in `the workflow's GITHUB_TOKEN has no packages permission; add permissions: packages: write` | The job's `permissions` mapping leaves out `packages`. Add `packages: write` to push, or `packages: read` to pull. | | |
| 392 | +| A push fails with a registry error ending in `the workflow's GITHUB_TOKEN has packages: read; add permissions: packages: write to push` | The job has only read access to packages, often because it has no `permissions:` and the repository default is `read`. Add `packages: write`. | |
addedapps/diffinery/docs/actions/environments.md+912−0View file
| @@ -1,0 +1,912 @@ | ||
| 1 | +--- | |
| 2 | +title: Deployments and environments | |
| 3 | +description: Create deployment environments, limit which branches and tags deploy to them, give them their own secrets and variables, and track deployments. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +An environment is a named deployment target, such as `staging` or `production`, with its own secrets, variables and a policy that decides which branches and tags may deploy to it. A workflow job that names an environment gets that environment's secrets and variables, is refused if its ref isn't allowed, and records a deployment with statuses that other tools can follow. Diffinery implements GitHub's environments, deployments and deployment statuses, including their REST API and webhooks; the differences are listed in [Differences from GitHub](#differences-from-github). | |
| 7 | + | |
| 8 | +## How it fits together | |
| 9 | + | |
| 10 | +| Concept | What it is | | |
| 11 | +|---|---| | |
| 12 | +| Environment | A name in one repository (`production`), with an optional deployment branch policy, protection rules, secrets and variables. | | |
| 13 | +| Deployment branch policy | Which refs may use the environment: any ref (no policy), protected branches only, or a list of branch and tag patterns. | | |
| 14 | +| Required reviewers | Up to 6 people, one of whom must approve before a run's jobs deploy to the environment. | | |
| 15 | +| Wait timer | Minutes a job waits before it deploys, up to 43,200 (30 days). | | |
| 16 | +| Environment secret | An encrypted value that only jobs using the environment receive, and only when the rules in [Who receives environment secrets](#who-receives-environment-secrets) allow it. | | |
| 17 | +| Environment variable | A plain value that jobs using the environment receive in the `vars` context. | | |
| 18 | +| Deployment | A record that a commit was (or is being) deployed to an environment. Workflow jobs create one automatically; you can also create them with the REST API. | | |
| 19 | +| Deployment status | The state of a deployment over time: `queued`, `pending`, `in_progress`, `success`, `failure`, `error` or `inactive`, with optional log and environment URLs. | | |
| 20 | + | |
| 21 | +A typical setup: | |
| 22 | + | |
| 23 | +1. A repository admin creates `production`, limits it to protected branches, and stores the deploy credentials as environment secrets. | |
| 24 | +2. The deploy job in a workflow says `environment: production`. | |
| 25 | +3. When the job starts on `main` (a protected branch), Diffinery records a deployment with an `in_progress` status, and the job gets the `production` secrets and variables. | |
| 26 | +4. When the job finishes, the deployment gets a final status (`success`, `failure` or `inactive`). Webhooks receive a `deployment` event when the deployment is created and `deployment_status` events for each status. | |
| 27 | + | |
| 28 | +Repository admins manage environments under **Settings → Environments** (protection rules, deployment branches and their patterns, and environment secrets) and with the REST API, which follows GitHub's paths and JSON shapes, so `gh api`, Octokit and existing scripts work after you point them at `https://diffinery.com/api/v3`. Environment variables are managed with the API. | |
| 29 | + | |
| 30 | +## Create an environment | |
| 31 | + | |
| 32 | +An environment is created in one of three ways: | |
| 33 | + | |
| 34 | +- **Explicitly**, with `PUT /api/v3/repos/{owner}/{repo}/environments/{name}`. This needs a person with the admin role on the repository and a token with the `admin` scope. | |
| 35 | +- **By a workflow job** that names an environment that doesn't exist yet. The job creates it with no branch policy, secrets or variables, and the job runs. | |
| 36 | +- **By creating a deployment** with the REST API for an environment that doesn't exist yet. | |
| 37 | + | |
| 38 | +Environments created automatically have no branch policy, so every ref may use them. Create sensitive environments yourself, with a policy, before a workflow uses them. | |
| 39 | + | |
| 40 | +### Environment names | |
| 41 | + | |
| 42 | +- 1 to 255 characters, with no control characters, `/` or `\`. Anything else fails with `422 invalid environment name`. | |
| 43 | +- Names are case-insensitive: `Production` and `production` are the same environment. Once an environment exists, deployments, statuses and jobs that name it in another case use its existing spelling, and filtering deployments with `?environment=` ignores case. | |
| 44 | +- URL-encode the name in API paths, for example `environments/QA%20cluster`. | |
| 45 | + | |
| 46 | +### Create one with the API | |
| 47 | + | |
| 48 | +An empty body creates the environment with no branch policy: | |
| 49 | + | |
| 50 | +```sh | |
| 51 | +curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/staging \ | |
| 52 | + -H "Authorization: Bearer dfy_..." | |
| 53 | +``` | |
| 54 | + | |
| 55 | +Create one that only protected branches may use: | |
| 56 | + | |
| 57 | +```sh | |
| 58 | +curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production \ | |
| 59 | + -H "Authorization: Bearer dfy_..." \ | |
| 60 | + -H "Content-Type: application/json" \ | |
| 61 | + -d '{"deployment_branch_policy": {"protected_branches": true, "custom_branch_policies": false}}' | |
| 62 | +``` | |
| 63 | + | |
| 64 | +The response (`200 OK`) is the environment: | |
| 65 | + | |
| 66 | +```json | |
| 67 | +{ | |
| 68 | + "id": 1, | |
| 69 | + "node_id": "RW52aXJvbm1lbnQ6YWNtZS93ZWJzaXRlLzE", | |
| 70 | + "name": "production", | |
| 71 | + "url": "https://diffinery.com/api/v3/repos/acme/website/environments/production", | |
| 72 | + "html_url": "https://diffinery.com/acme/website/settings/environments/production", | |
| 73 | + "created_at": "2026-10-13T09:00:00Z", | |
| 74 | + "updated_at": "2026-10-13T09:00:00Z", | |
| 75 | + "can_admins_bypass": false, | |
| 76 | + "protection_rules": [{ "id": 13, "node_id": "QnJhbmNoUG9saWN5OjE", "type": "branch_policy" }], | |
| 77 | + "deployment_branch_policy": { "protected_branches": true, "custom_branch_policies": false } | |
| 78 | +} | |
| 79 | +``` | |
| 80 | + | |
| 81 | +Notes on the fields: | |
| 82 | + | |
| 83 | +- `protection_rules` lists a `wait_timer` entry (with `wait_timer`), a `required_reviewers` entry (with `prevent_self_review` and `reviewers`, each `{"type": "User", "reviewer": {...}}`) and a `branch_policy` entry, for the rules the environment has. See [Required reviewers and wait timers](#required-reviewers-and-wait-timers). | |
| 84 | +- `can_admins_bypass` is always `false`: nobody bypasses an environment's rules, admins included. | |
| 85 | +- `html_url` is the environment's page under **Settings → Environments**. | |
| 86 | + | |
| 87 | +`PUT` on an existing environment updates it. Fields you leave out keep their values. The body accepts: | |
| 88 | + | |
| 89 | +| Field | Effect | | |
| 90 | +|---|---| | |
| 91 | +| `deployment_branch_policy` | `null` removes the policy (any ref may deploy). An object sets exactly one of `protected_branches` and `custom_branch_policies` to `true`. Leave the field out to keep the current policy. | | |
| 92 | +| `wait_timer` | Minutes a job waits before it deploys, from `0` to `43200`. Anything else fails with `422 wait_timer must be a whole number of minutes from 0 to 43200`. | | |
| 93 | +| `reviewers` | Up to 6 required reviewers, each `{"type": "User", "id": <id>}` with the numeric `id` Diffinery's API shows for the person, or `{"type": "User", "login": "alice"}`. `[]` or `null` removes them. Each must be a person with at least read access: an agent fails with `422 reviewers[0]: <login> is an agent; only people can be required reviewers`, and `{"type": "Team"}` fails because Diffinery has no teams. | | |
| 94 | +| `prevent_self_review` | `true` stops whoever started a run from approving it. See [Prevent self-review](#prevent-self-review). | | |
| 95 | + | |
| 96 | +### List, get and delete environments | |
| 97 | + | |
| 98 | +```sh | |
| 99 | +# Every environment, sorted by name (read access) | |
| 100 | +curl https://diffinery.com/api/v3/repos/acme/website/environments \ | |
| 101 | + -H "Authorization: Bearer dfy_..." | |
| 102 | + | |
| 103 | +# One environment (read access) | |
| 104 | +curl https://diffinery.com/api/v3/repos/acme/website/environments/production \ | |
| 105 | + -H "Authorization: Bearer dfy_..." | |
| 106 | + | |
| 107 | +# Delete it, with its secrets and variables (admin, people only) | |
| 108 | +curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/environments/staging \ | |
| 109 | + -H "Authorization: Bearer dfy_..." | |
| 110 | +``` | |
| 111 | + | |
| 112 | +The list returns `{"total_count": 2, "environments": [...]}` and takes `per_page` (default 30, at most 100) and `page`. Deleting returns `204 No Content` and removes the environment's secrets and variables. The deployments recorded for it stay. A missing environment returns `404 environment not found`. | |
| 113 | + | |
| 114 | +## Deployment branch policies | |
| 115 | + | |
| 116 | +A branch policy decides which refs a workflow job may use the environment from. It's checked when a job with `environment:` starts. A job whose ref isn't allowed fails before any step runs, with: | |
| 117 | + | |
| 118 | +```text | |
| 119 | +feature/login is not allowed to deploy to production due to environment protection rules. | |
| 120 | +``` | |
| 121 | + | |
| 122 | +The policy has three settings: | |
| 123 | + | |
| 124 | +| Setting | API value | Which refs may deploy | | |
| 125 | +|---|---|---| | |
| 126 | +| No policy | `"deployment_branch_policy": null` | Every branch, tag and pull request ref. | | |
| 127 | +| Protected branches only | `{"protected_branches": true, "custom_branch_policies": false}` | Branches that a branch protection rule matches. Tags and pull request refs never. | | |
| 128 | +| Selected branches and tags | `{"protected_branches": false, "custom_branch_policies": true}` | Branches and tags that match one of the environment's patterns. Pull request refs never. | | |
| 129 | + | |
| 130 | +Setting both values to `true`, or both to `false`, fails with `422 set exactly one of protected_branches and custom_branch_policies`. | |
| 131 | + | |
| 132 | +In the web UI, open **Settings → Environments → (environment)**, choose **No restriction**, **Protected branches only** or **Selected branches and tags** under **Deployment branches**, and select **Save protection rules**. With **Selected branches and tags** saved, a **Branch and tag patterns** box appears below, where you add a pattern (choose whether it matches branches or tags) and remove one. | |
| 133 | + | |
| 134 | +The branch policy only applies to workflow jobs. A deployment created directly with the REST API isn't checked against it. | |
| 135 | + | |
| 136 | +### Protected branches only | |
| 137 | + | |
| 138 | +A branch counts as protected when one of the repository's branch protection rules matches its name (see [Branch protection](/docs/repositories/branch-protection)). The default branch is only protected for this purpose when a rule matches it, so add a rule for `main` if production deploys from `main`. | |
| 139 | + | |
| 140 | +This is the setting to use for environments whose secrets matter: changes reach protected branches through reviewed pull requests, and it's the only setting under which runs whose code came from an agent receive environment secrets. | |
| 141 | + | |
| 142 | +### Selected branches and tags | |
| 143 | + | |
| 144 | +With `custom_branch_policies`, the environment allows the refs that match its patterns, and nothing else. A new custom policy has no patterns, so no ref may deploy until you add one. | |
| 145 | + | |
| 146 | +Add a pattern (admin, people only): | |
| 147 | + | |
| 148 | +```sh | |
| 149 | +curl -X POST https://diffinery.com/api/v3/repos/acme/website/environments/production/deployment-branch-policies \ | |
| 150 | + -H "Authorization: Bearer dfy_..." \ | |
| 151 | + -H "Content-Type: application/json" \ | |
| 152 | + -d '{"name": "release/*", "type": "branch"}' | |
| 153 | +``` | |
| 154 | + | |
| 155 | +```json | |
| 156 | +{ "id": 1, "name": "release/*", "type": "branch", "node_id": "QnJhbmNoUG9saWN5OjEvMQ" } | |
| 157 | +``` | |
| 158 | + | |
| 159 | +`type` is `branch` (the default) or `tag`. A branch pattern is matched against branch names (`refs/heads/<name>`), and a tag pattern against tag names (`refs/tags/<name>`). Adding a pattern to an environment that doesn't use `custom_branch_policies` fails with `422 enable custom_branch_policies on the environment first`; an empty `name` fails with `422 name is required`. | |
| 160 | + | |
| 161 | +List and delete patterns: | |
| 162 | + | |
| 163 | +```sh | |
| 164 | +curl https://diffinery.com/api/v3/repos/acme/website/environments/production/deployment-branch-policies \ | |
| 165 | + -H "Authorization: Bearer dfy_..." | |
| 166 | + | |
| 167 | +curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/environments/production/deployment-branch-policies/1 \ | |
| 168 | + -H "Authorization: Bearer dfy_..." | |
| 169 | +``` | |
| 170 | + | |
| 171 | +The list returns `{"total_count": 1, "branch_policies": [{"id": 1, "name": "release/*", "type": "branch", "node_id": "..."}]}`. Switching an environment from custom patterns to protected branches only clears its patterns; switching back to custom patterns starts empty. | |
| 172 | + | |
| 173 | +Pattern syntax: | |
| 174 | + | |
| 175 | +| Pattern | Matches | | |
| 176 | +|---|---| | |
| 177 | +| `*` | Any characters except `/` | | |
| 178 | +| `**` | Any characters, including `/` | | |
| 179 | +| `?` | One character other than `/` | | |
| 180 | +| Anything else | Itself, case-sensitively | | |
| 181 | + | |
| 182 | +Examples: | |
| 183 | + | |
| 184 | +| Pattern | Type | Matches | Doesn't match | | |
| 185 | +|---|---|---|---| | |
| 186 | +| `main` | branch | `main` | `main-2`, `Main` | | |
| 187 | +| `release/*` | branch | `release/1.4` | `release/1.4/hotfix`, `release` | | |
| 188 | +| `release/**` | branch | `release/1.4`, `release/1.4/hotfix` | `releases/1.4` | | |
| 189 | +| `v*` | tag | `v1.4.0`, `v2` | `1.4.0` | | |
| 190 | +| `v?.*` | tag | `v1.4`, `v2.0` | `v10.0` | | |
| 191 | + | |
| 192 | +> **Important:** A tag pattern never matches a tag that an agent created or last moved, whether it pushed the tag with git, created it with a release or wrote it through the API. Diffinery remembers who last moved each tag. Tags that people push can deploy through a tag pattern; tags that agents push can't. | |
| 193 | + | |
| 194 | +### The default policy when the first secret is added | |
| 195 | + | |
| 196 | +When you add a secret to an environment that has no branch policy, Diffinery sets the policy to **protected branches only** at the same time. This keeps a new environment's secrets away from unreviewed branches. If you want selected branches and tags instead, set `custom_branch_policies` (before or after adding the secret) and add patterns. If you remove the policy afterwards with `"deployment_branch_policy": null`, it stays removed. | |
| 197 | + | |
| 198 | +## Required reviewers and wait timers | |
| 199 | + | |
| 200 | +Protection rules hold a job before it starts. When a job that names the environment is ready to run, and the environment has required reviewers or a wait timer, the job waits. No runner starts, no token is minted and no secret leaves Diffinery until the rules pass. The job's status is `waiting` (shown as **Waiting for approval** in the web UI), the run's status is `waiting`, and the job's deployment gets a `waiting` status. A waiting job holds no concurrency group and none of the repository's hosted-runner slots, so other jobs keep running. | |
| 201 | + | |
| 202 | +The branch policy is checked first. A ref that may never deploy to the environment fails at once, without asking anyone. | |
| 203 | + | |
| 204 | +Set the rules under **Settings → Environments → (environment)**, or with the API: | |
| 205 | + | |
| 206 | +```sh | |
| 207 | +curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production \ | |
| 208 | + -H "Authorization: Bearer dfy_..." \ | |
| 209 | + -H "Content-Type: application/json" \ | |
| 210 | + -d '{"reviewers": [{"type": "User", "login": "alice"}, {"type": "User", "login": "bob"}], "wait_timer": 10, "prevent_self_review": true}' | |
| 211 | +``` | |
| 212 | + | |
| 213 | +### Required reviewers | |
| 214 | + | |
| 215 | +- One approval from any listed reviewer lets the run's jobs for that environment go on. A rejection fails them, with `The deployment to production was rejected by alice: <comment>`, and jobs that need them are skipped as usual. | |
| 216 | +- The first decision on an environment in a run is the one that counts. Approving covers every job and matrix leg of that run that names the environment. | |
| 217 | +- Reviewers get a notification and an email (Actions email settings apply), the environment shows under **Deployments waiting for you** on [Today](/docs/agents/today), and the run page shows a **Review deployments** box with each environment they may approve, a comment and **Approve and deploy** or **Reject**. | |
| 218 | +- Only a person can approve, signed in to the web UI or with a personal token that has the `write` scope. Agents, a workflow's `GITHUB_TOKEN` and sandbox tokens can't, even when an agent's account is listed by mistake. Being listed is enough; a reviewer with read access may approve. That access is checked again when they decide, and it has to be access they were given (as the owner, a collaborator or an organization member): on a public repository, a reviewer removed from it can't approve, even though they can still read it. | |
| 219 | +- A job waits at most 30 days. After that it fails with `The deployment to production wasn't approved within 30 days.` | |
| 220 | + | |
| 221 | +### Prevent self-review | |
| 222 | + | |
| 223 | +With `prevent_self_review`, these people can't approve a run, even when they're listed: | |
| 224 | + | |
| 225 | +- whoever started the run, | |
| 226 | +- whoever started an earlier attempt of a re-run, | |
| 227 | +- the person who owns the agent that started it. | |
| 228 | + | |
| 229 | +This compares accounts, not logins, so renaming an account doesn't get around it. | |
| 230 | + | |
| 231 | +They see why on the run page, and the API answers `403 production doesn't let whoever started a run approve it`. | |
| 232 | + | |
| 233 | +### Wait timers | |
| 234 | + | |
| 235 | +A wait timer delays the job by that many minutes, counted from when it became ready to start. With reviewers too, the job starts once someone approved and the timer has passed, whichever comes last. Nobody can skip a timer. | |
| 236 | + | |
| 237 | +### Re-runs | |
| 238 | + | |
| 239 | +A re-run is a new run: its jobs wait again and need a new approval. The timer starts again too. A job that a failed-job or single-job re-run keeps from the earlier attempt doesn't run again, so it neither waits nor deploys. | |
| 240 | + | |
| 241 | +### Review with the API | |
| 242 | + | |
| 243 | +```sh | |
| 244 | +# Environments the run waits on, and whether you may approve them | |
| 245 | +curl https://diffinery.com/api/v3/repos/acme/website/actions/runs/{run_id}/pending_deployments \ | |
| 246 | + -H "Authorization: Bearer dfy_..." | |
| 247 | + | |
| 248 | +# Approve (or "rejected"); returns the deployments of the jobs that were waiting | |
| 249 | +curl -X POST https://diffinery.com/api/v3/repos/acme/website/actions/runs/{run_id}/pending_deployments \ | |
| 250 | + -H "Authorization: Bearer dfy_..." \ | |
| 251 | + -H "Content-Type: application/json" \ | |
| 252 | + -d '{"environment_ids": [1], "state": "approved", "comment": "Ship it"}' | |
| 253 | + | |
| 254 | +# Past decisions on the run | |
| 255 | +curl https://diffinery.com/api/v3/repos/acme/website/actions/runs/{run_id}/approvals \ | |
| 256 | + -H "Authorization: Bearer dfy_..." | |
| 257 | +``` | |
| 258 | + | |
| 259 | +A pending deployment looks like GitHub's: | |
| 260 | + | |
| 261 | +```json | |
| 262 | +[ | |
| 263 | + { | |
| 264 | + "environment": { "id": 1, "node_id": "...", "name": "production", "url": "...", "html_url": "..." }, | |
| 265 | + "wait_timer": 10, | |
| 266 | + "wait_timer_started_at": "2026-10-13T09:30:00Z", | |
| 267 | + "current_user_can_approve": true, | |
| 268 | + "reviewers": [{ "type": "User", "reviewer": { "login": "alice", "id": 12345, "type": "User" } }] | |
| 269 | + } | |
| 270 | +] | |
| 271 | +``` | |
| 272 | + | |
| 273 | +| Error | Meaning | | |
| 274 | +|---|---| | |
| 275 | +| `422 environment 1 isn't waiting for a review in this run` | Nothing in the run waits on that environment, or someone already decided. | | |
| 276 | +| `403 you aren't a required reviewer for production` | Only listed reviewers may decide. | | |
| 277 | +| `403 only people can review deployments, not agents` | Agents never approve deployments. | | |
| 278 | +| `403 reviewing deployments needs a token that may write` | Use a token with the `write` scope. | | |
| 279 | +| `403 only people with access to this repository review its deployments` | The reviewer was removed from the repository after being listed. Add them back, or change the environment's reviewers. | | |
| 280 | + | |
| 281 | +Each approval and rejection is in the audit log as `deployment.approve` or `deployment.reject`, with the comment. | |
| 282 | + | |
| 283 | +## Environment secrets and variables | |
| 284 | + | |
| 285 | +Environment secrets and variables work like repository ones, but only jobs that use the environment receive them. Secrets are managed under **Settings → Environments → (environment) → Environment secrets** or with the REST API; variables with the REST API. Secret values are encrypted at rest with a key specific to the repository and the environment. | |
| 286 | + | |
| 287 | +### Environment secrets | |
| 288 | + | |
| 289 | +Setting and deleting environment secrets needs a person (not an agent) with the admin role on the repository, and with the API a token with the `admin` scope. The environment must exist. | |
| 290 | + | |
| 291 | +In the web UI, the **Environment secrets** box on the environment's page lists the secret names with when each was last updated. Enter a name and a value and select **Save secret** to add one or replace its value, or select **Delete** next to a name. Values are never shown again after you save them. | |
| 292 | + | |
| 293 | +```sh | |
| 294 | +# Create or replace a secret: 201 Created when new, 204 No Content when replaced | |
| 295 | +curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \ | |
| 296 | + -H "Authorization: Bearer dfy_..." \ | |
| 297 | + -H "Content-Type: application/json" \ | |
| 298 | + -d '{"value": "cf_..."}' | |
| 299 | + | |
| 300 | +# List names (values are never returned) | |
| 301 | +curl https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets \ | |
| 302 | + -H "Authorization: Bearer dfy_..." | |
| 303 | + | |
| 304 | +# One secret's metadata | |
| 305 | +curl https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \ | |
| 306 | + -H "Authorization: Bearer dfy_..." | |
| 307 | + | |
| 308 | +# Delete (204 No Content) | |
| 309 | +curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \ | |
| 310 | + -H "Authorization: Bearer dfy_..." | |
| 311 | +``` | |
| 312 | + | |
| 313 | +The list returns `{"total_count": 1, "secrets": [{"name": "CLOUDFLARE_API_TOKEN", "created_at": "...", "updated_at": "..."}]}`; listing and reading need the admin role. | |
| 314 | + | |
| 315 | +Rules: | |
| 316 | + | |
| 317 | +- Names use letters, digits and underscores, don't start with a digit, and don't start with `GITHUB_`. Anything else fails with `422 invalid secret name`. Names are stored in uppercase. | |
| 318 | +- Send the value as `value`, in plain JSON over HTTPS. A body with only GitHub's sealed-box `encrypted_value` fails with ``422 encrypted_value (sealed box) is not supported yet: send the secret as `value` over HTTPS; it is encrypted at rest``. There's no `public-key` endpoint. | |
| 319 | +- Adding the first secret to an environment without a branch policy sets it to protected branches only (see [The default policy when the first secret is added](#the-default-policy-when-the-first-secret-is-added)). | |
| 320 | +- Agents can't set or delete them, whatever their role (`403 agents cannot manage secrets` for an agent with the admin role). | |
| 321 | + | |
| 322 | +### Environment variables | |
| 323 | + | |
| 324 | +| Request | Needs | Does | | |
| 325 | +|---|---|---| | |
| 326 | +| `GET /api/v3/repos/{owner}/{repo}/environments/{env}/variables` | write role | Returns `{"total_count": 1, "variables": [{"name", "value", "created_at", "updated_at"}]}`. `per_page` defaults to 10 (at most 100). | | |
| 327 | +| `GET /api/v3/repos/{owner}/{repo}/environments/{env}/variables/{name}` | write role | Returns one variable, or `404 Not Found`. | | |
| 328 | +| `POST /api/v3/repos/{owner}/{repo}/environments/{env}/variables` with `{"name": "...", "value": "..."}` | admin role, people only | Creates a variable (`201`). An existing name fails with `409 Already exists - Variable already exists`. | | |
| 329 | +| `PATCH /api/v3/repos/{owner}/{repo}/environments/{env}/variables/{name}` with `{"value": "..."}` and optionally a new `"name"` | admin role, people only | Updates or renames a variable (`204`), or `404 Not Found`. Renaming onto a name that already exists fails with `409 Already exists - Variable already exists`. | | |
| 330 | +| `DELETE /api/v3/repos/{owner}/{repo}/environments/{env}/variables/{name}` | admin role, people only | Deletes a variable (`204`). | | |
| 331 | + | |
| 332 | +```sh | |
| 333 | +curl -X POST https://diffinery.com/api/v3/repos/acme/website/environments/production/variables \ | |
| 334 | + -H "Authorization: Bearer dfy_..." \ | |
| 335 | + -H "Content-Type: application/json" \ | |
| 336 | + -d '{"name": "DEPLOY_URL", "value": "https://www.example.com"}' | |
| 337 | +``` | |
| 338 | + | |
| 339 | +Variable names follow the same rules as secret names (`422 invalid variable name` otherwise) and are stored in uppercase. Variables are not secret: anyone with write access can read them. Agents can't create, change or delete them, whatever their role (`403 agents cannot manage variables` for an agent with the admin role). | |
| 340 | + | |
| 341 | +### Who receives environment secrets | |
| 342 | + | |
| 343 | +A job that uses an environment receives its secrets only when all of these hold: | |
| 344 | + | |
| 345 | +1. The environment's branch policy allows the job's ref (otherwise the job fails, see [Deployment branch policies](#deployment-branch-policies)). | |
| 346 | +2. The run isn't on a pull request ref (`refs/pull/<number>/merge`). Environment secrets never go to `pull_request` runs, whatever the policy says. | |
| 347 | +3. If the run's code came from an agent, the environment is set to **protected branches only**. With no policy or with custom patterns, those runs get no environment secrets. | |
| 348 | + | |
| 349 | +A run's code came from an agent when any of these is true: | |
| 350 | + | |
| 351 | +- An agent started the run, for example with a push, a `workflow_dispatch` or a re-run. | |
| 352 | +- The run is for a branch whose last push was made by an agent. | |
| 353 | +- The run is for a pull request that an agent opened, or whose head branch an agent pushed to last. | |
| 354 | +- The run is a re-run of a run whose code came from an agent, whoever starts the re-run. | |
| 355 | +- The run was started by `workflow_run` after a run whose code came from an agent (it acts on that run's artifacts and outputs). | |
| 356 | + | |
| 357 | +In such a run the job also acts as an agent: its deployment's creator is recorded as an agent, and its `GITHUB_TOKEN` follows the rules for agents (see [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent)). | |
| 358 | + | |
| 359 | +When a rule withholds the secrets, the job still runs with the environment's variables, and `${{ secrets.NAME }}` for an environment secret evaluates to an empty string. | |
| 360 | + | |
| 361 | +| Run | No policy | Protected branches only | Selected branches and tags | | |
| 362 | +|---|---|---|---| | |
| 363 | +| Started by a person, on an allowed branch or tag | Secrets | Secrets | Secrets | | |
| 364 | +| Code from an agent, on an allowed branch | No secrets | Secrets | No secrets | | |
| 365 | +| `pull_request` run (`refs/pull/<n>/merge`) | Job runs, no secrets | Job fails (ref not allowed) | Job fails (ref not allowed) | | |
| 366 | +| Ref not allowed by the policy | Not applicable | Job fails | Job fails | | |
| 367 | + | |
| 368 | +`pull_request_target` runs use the base branch as their ref, so they're treated like a run on that branch. | |
| 369 | + | |
| 370 | +### Precedence | |
| 371 | + | |
| 372 | +In a job that uses an environment: | |
| 373 | + | |
| 374 | +- An environment secret replaces a repository or organization secret with the same name (and, in a called reusable workflow, a secret passed by the caller). | |
| 375 | +- An environment variable replaces a repository variable with the same name. | |
| 376 | +- `GITHUB_TOKEN` and `DIFFINERY_TOKEN` are always the job's own token. | |
| 377 | + | |
| 378 | +Values are read when the job starts, so changes affect jobs that start afterwards. Environment secrets are masked in logs like every other secret. See [Secrets and variables](/docs/actions/secrets-and-variables) for masking, names and repository secrets. | |
| 379 | + | |
| 380 | +## Use an environment in a job | |
| 381 | + | |
| 382 | +### Syntax | |
| 383 | + | |
| 384 | +Name the environment with a string: | |
| 385 | + | |
| 386 | +```yaml | |
| 387 | +jobs: | |
| 388 | + deploy: | |
| 389 | + runs-on: ubuntu-latest | |
| 390 | + environment: production | |
| 391 | + steps: | |
| 392 | + - run: ./deploy.sh | |
| 393 | +``` | |
| 394 | + | |
| 395 | +Or with a mapping that also gives the URL of the deployed site: | |
| 396 | + | |
| 397 | +```yaml | |
| 398 | +jobs: | |
| 399 | + deploy: | |
| 400 | + runs-on: ubuntu-latest | |
| 401 | + environment: | |
| 402 | + name: production | |
| 403 | + url: https://www.example.com | |
| 404 | + steps: | |
| 405 | + - run: ./deploy.sh | |
| 406 | +``` | |
| 407 | + | |
| 408 | +| Key | Description | | |
| 409 | +|---|---| | |
| 410 | +| `name` | The environment's name (required in the mapping form). Expressions are allowed. | | |
| 411 | +| `url` | Optional. Stored as the deployment status's `environment_url` when it's an `http://` or `https://` URL; other values are ignored. Expressions are allowed. | | |
| 412 | + | |
| 413 | +Both values can use the `github`, `inputs`, `vars`, `matrix`, `strategy` and `needs` contexts. Other contexts evaluate to empty: | |
| 414 | + | |
| 415 | +- A `name` that evaluates to an empty string fails the job with `invalid environment name ''`. | |
| 416 | +- A `url` that refers to `steps` (for example `${{ steps.deploy.outputs.url }}`, which GitHub allows) is ignored, so the deployment has no environment URL. Compute the URL from `inputs`, `vars`, `matrix` or a job output in `needs` instead, or post a deployment status yourself (see [Deployment statuses](#deployment-statuses)). | |
| 417 | + | |
| 418 | +```yaml | |
| 419 | +jobs: | |
| 420 | + deploy: | |
| 421 | + runs-on: ubuntu-latest | |
| 422 | + strategy: | |
| 423 | + matrix: | |
| 424 | + target: [staging, production] | |
| 425 | + environment: | |
| 426 | + name: ${{ matrix.target }} | |
| 427 | + url: https://${{ matrix.target }}.example.com | |
| 428 | + steps: | |
| 429 | + - run: ./deploy.sh "${{ matrix.target }}" | |
| 430 | +``` | |
| 431 | + | |
| 432 | +`environment` belongs on jobs that have `steps`. On a job that calls a reusable workflow (`uses:`), it produces the warning `unknown reusable-workflow job key 'environment'` and is ignored; put `environment:` on the jobs inside the called workflow instead (see [Reusable workflows](#reusable-workflows)). | |
| 433 | + | |
| 434 | +### What happens when the job starts | |
| 435 | + | |
| 436 | +When a job with `environment:` is ready to run, Diffinery evaluates `name` once, and that name is the one the job deploys to. If the environment has required reviewers or a wait timer, the job [waits for them](#required-reviewers-and-wait-timers) first. When a hosted or self-hosted runner then starts the job, Diffinery: | |
| 437 | + | |
| 438 | +1. Evaluates `url`. | |
| 439 | +2. Creates the environment if it doesn't exist (with no branch policy). | |
| 440 | +3. Checks the branch policy against the run's ref, and fails the job if the ref isn't allowed. A job whose environment needs an approval it didn't get fails with `The environment production requires approval this job didn't get. Re-run the job to ask for it.` (This happens when someone adds reviewers while the job is already on its way to a runner.) | |
| 441 | +4. Creates a deployment for the job: `ref` is the branch or tag name (or `refs/pull/<n>/merge`), `sha` is the commit the run checked out, `task` is `deploy`, `payload` is `{}`, the creator is the person or agent who started the run (an agent when the run's code came from an agent), and `production_environment` is `true` when the environment is named `production` in any case (`Production` counts). | |
| 442 | +5. Sends a `deployment` webhook event with the action `created`, then adds an `in_progress` status, with `log_url` set to the job's page (`https://diffinery.com/acme/website/actions/runs/<run id>/job/<job id>`) and `environment_url` from `url`. | |
| 443 | +6. Passes the environment's variables to the job, and its secrets when [the rules](#who-receives-environment-secrets) allow it. | |
| 444 | + | |
| 445 | +A job creates one deployment, even if its runner fetches the job more than once. A re-run creates a new deployment, because it's a new job. The deployment's commit statuses and checks aren't required to have passed, unlike [deployments created with the API](#required-contexts). | |
| 446 | + | |
| 447 | +### Automatic deployment statuses | |
| 448 | + | |
| 449 | +When the job finishes, its deployment gets a final status with the same `log_url` and `environment_url`: | |
| 450 | + | |
| 451 | +| Job conclusion | Deployment status | | |
| 452 | +|---|---| | |
| 453 | +| `success` | `success` | | |
| 454 | +| `cancelled` or `skipped` | `inactive` | | |
| 455 | +| Anything else (`failure`, timed out) | `failure` | | |
| 456 | + | |
| 457 | +A `success` status also marks older deployments to the same environment as `inactive` when their latest status is `success`, unless they are transient or production deployments. Deployments created after this one are left alone. See [auto_inactive](#deployment-statuses). | |
| 458 | + | |
| 459 | +### Reusable workflows | |
| 460 | + | |
| 461 | +Jobs inside a called workflow can have `environment:`. The environment, its branch policy, secrets and variables come from the **calling** repository (the repository whose run it is), and the policy is checked against the caller's ref. Environment secrets are added to the job even though the caller didn't pass them in `secrets:`, as on GitHub. The environment name can use the called workflow's `inputs`: | |
| 462 | + | |
| 463 | +```yaml | |
| 464 | +# .github/workflows/deploy.yml | |
| 465 | +on: | |
| 466 | + workflow_call: | |
| 467 | + inputs: | |
| 468 | + target: | |
| 469 | + type: string | |
| 470 | + required: true | |
| 471 | + | |
| 472 | +jobs: | |
| 473 | + deploy: | |
| 474 | + runs-on: ubuntu-latest | |
| 475 | + environment: | |
| 476 | + name: ${{ inputs.target }} | |
| 477 | + url: https://${{ inputs.target }}.example.com | |
| 478 | + steps: | |
| 479 | + - uses: actions/checkout@v4 | |
| 480 | + - run: npx wrangler deploy --env "${{ inputs.target }}" | |
| 481 | + env: | |
| 482 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 483 | +``` | |
| 484 | + | |
| 485 | +```yaml | |
| 486 | +# .github/workflows/release.yml | |
| 487 | +on: | |
| 488 | + push: | |
| 489 | + branches: [main] | |
| 490 | + | |
| 491 | +jobs: | |
| 492 | + staging: | |
| 493 | + uses: ./.github/workflows/deploy.yml | |
| 494 | + with: | |
| 495 | + target: staging | |
| 496 | + production: | |
| 497 | + needs: staging | |
| 498 | + uses: ./.github/workflows/deploy.yml | |
| 499 | + with: | |
| 500 | + target: production | |
| 501 | +``` | |
| 502 | + | |
| 503 | +Here `CLOUDFLARE_API_TOKEN` can be a different environment secret in `staging` and in `production`. See [Reusable workflows](/docs/actions/reusable-workflows). | |
| 504 | + | |
| 505 | +### Choosing an environment when you run a workflow manually | |
| 506 | + | |
| 507 | +A `workflow_dispatch` input with `type: environment` is accepted. It's shown as a text field when you run the workflow from the web UI, and its value isn't checked against the repository's environments. Use it like a string input: | |
| 508 | + | |
| 509 | +```yaml | |
| 510 | +on: | |
| 511 | + workflow_dispatch: | |
| 512 | + inputs: | |
| 513 | + target: | |
| 514 | + type: environment | |
| 515 | + required: true | |
| 516 | + | |
| 517 | +jobs: | |
| 518 | + deploy: | |
| 519 | + runs-on: ubuntu-latest | |
| 520 | + environment: ${{ inputs.target }} | |
| 521 | + steps: | |
| 522 | + - run: ./deploy.sh | |
| 523 | +``` | |
| 524 | + | |
| 525 | +Because a dispatched run can name any environment, the environment's branch policy is what keeps a branch from deploying where it shouldn't. | |
| 526 | + | |
| 527 | +### Example: staging on main, production on version tags | |
| 528 | + | |
| 529 | +1. Set up the environments (as a repository admin): | |
| 530 | + | |
| 531 | + ```sh | |
| 532 | + api=https://diffinery.com/api/v3/repos/acme/website | |
| 533 | + auth="Authorization: Bearer dfy_..." | |
| 534 | + | |
| 535 | + # staging: protected branches only (add a branch protection rule for main first) | |
| 536 | + curl -X PUT "$api/environments/staging" -H "$auth" -H "Content-Type: application/json" \ | |
| 537 | + -d '{"deployment_branch_policy": {"protected_branches": true, "custom_branch_policies": false}}' | |
| 538 | + | |
| 539 | + # production: only tags that match v* | |
| 540 | + curl -X PUT "$api/environments/production" -H "$auth" -H "Content-Type: application/json" \ | |
| 541 | + -d '{"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}' | |
| 542 | + curl -X POST "$api/environments/production/deployment-branch-policies" -H "$auth" \ | |
| 543 | + -H "Content-Type: application/json" -d '{"name": "v*", "type": "tag"}' | |
| 544 | + | |
| 545 | + # A different token for each environment | |
| 546 | + curl -X PUT "$api/environments/staging/secrets/CLOUDFLARE_API_TOKEN" -H "$auth" \ | |
| 547 | + -H "Content-Type: application/json" -d '{"value": "cf_staging_..."}' | |
| 548 | + curl -X PUT "$api/environments/production/secrets/CLOUDFLARE_API_TOKEN" -H "$auth" \ | |
| 549 | + -H "Content-Type: application/json" -d '{"value": "cf_production_..."}' | |
| 550 | + ``` | |
| 551 | + | |
| 552 | +2. Write the workflow: | |
| 553 | + | |
| 554 | + ```yaml | |
| 555 | + name: Deploy | |
| 556 | + on: | |
| 557 | + push: | |
| 558 | + branches: [main] | |
| 559 | + tags: ["v*"] | |
| 560 | + | |
| 561 | + jobs: | |
| 562 | + staging: | |
| 563 | + if: github.ref == 'refs/heads/main' | |
| 564 | + runs-on: ubuntu-latest | |
| 565 | + environment: | |
| 566 | + name: staging | |
| 567 | + url: https://staging.example.com | |
| 568 | + steps: | |
| 569 | + - uses: actions/checkout@v4 | |
| 570 | + - run: npm ci && npm run build | |
| 571 | + - run: npx wrangler deploy --env staging | |
| 572 | + env: | |
| 573 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 574 | + | |
| 575 | + production: | |
| 576 | + if: startsWith(github.ref, 'refs/tags/v') | |
| 577 | + runs-on: ubuntu-latest | |
| 578 | + environment: | |
| 579 | + name: production | |
| 580 | + url: https://www.example.com | |
| 581 | + steps: | |
| 582 | + - uses: actions/checkout@v4 | |
| 583 | + - run: npm ci && npm run build | |
| 584 | + - run: npx wrangler deploy --env production | |
| 585 | + env: | |
| 586 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 587 | + ``` | |
| 588 | + | |
| 589 | +Pushing to `main` deploys to staging; a person pushing the tag `v1.4.0` deploys to production. A tag pushed by an agent fails the production job with the "not allowed to deploy" message. | |
| 590 | + | |
| 591 | +## Deployments API | |
| 592 | + | |
| 593 | +You can create deployments yourself, for example from a deploy tool outside Diffinery Actions, and report their progress with statuses. The endpoints follow GitHub's REST API. Creating deployments and statuses needs the write role and a token with the `write` scope; reading needs read access. Agents with write access can create deployments and statuses. | |
| 594 | + | |
| 595 | +A workflow step can create deployments and statuses with `GITHUB_TOKEN` when the job has `deployments: write` in its `permissions:`. Without it, the request fails with `403 Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: deployments: write)`, or `403 write access required` when the token has no write scope at all. Often `environment:` on the job is simpler, because it records the deployment for you. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions). | |
| 596 | + | |
| 597 | +```yaml | |
| 598 | +jobs: | |
| 599 | + deploy: | |
| 600 | + runs-on: ubuntu-latest | |
| 601 | + permissions: | |
| 602 | + contents: read | |
| 603 | + deployments: write | |
| 604 | + steps: | |
| 605 | + - run: | | |
| 606 | + curl -fsS -X POST "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/deployments" \ | |
| 607 | + -H "Authorization: Bearer $GITHUB_TOKEN" \ | |
| 608 | + -H "Content-Type: application/json" \ | |
| 609 | + -d '{"ref": "${{ github.sha }}", "environment": "preview", "required_contexts": []}' | |
| 610 | + env: | |
| 611 | + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| 612 | +``` | |
| 613 | + | |
| 614 | +### Create a deployment | |
| 615 | + | |
| 616 | +```sh | |
| 617 | +curl -X POST https://diffinery.com/api/v3/repos/acme/website/deployments \ | |
| 618 | + -H "Authorization: Bearer dfy_..." \ | |
| 619 | + -H "Content-Type: application/json" \ | |
| 620 | + -d '{"ref": "main", "environment": "production", "description": "Deploy 1.4.0", "required_contexts": ["ci/build"]}' | |
| 621 | +``` | |
| 622 | + | |
| 623 | +| Field | Default | Description | | |
| 624 | +|---|---|---| | |
| 625 | +| `ref` | required | A branch, tag or commit SHA. Missing: `422 ref is required`. Unknown: `422 no ref found for: <ref>`. | | |
| 626 | +| `environment` | `production` | The environment's name. It's created if it doesn't exist. | | |
| 627 | +| `task` | `deploy` | Free text. Only the first 100 characters are kept. | | |
| 628 | +| `payload` | `{}` | Any JSON value, or a string. Returned as it was sent. | | |
| 629 | +| `description` | `null` | Free text. Only the first 1,000 characters are kept. | | |
| 630 | +| `required_contexts` | every context | See [Required contexts](#required-contexts). Up to 100 strings. | | |
| 631 | +| `transient_environment` | `false` | Marks the environment as temporary (it's skipped by `auto_inactive`). | | |
| 632 | +| `production_environment` | `true` when `environment` is `production`, in any case | Marks a production deployment (it's skipped by `auto_inactive`). | | |
| 633 | +| `auto_merge` | | Accepted and ignored. Diffinery never merges the default branch into `ref`. | | |
| 634 | + | |
| 635 | +The response is `201 Created` with the deployment (some user fields left out here): | |
| 636 | + | |
| 637 | +```json | |
| 638 | +{ | |
| 639 | + "url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7", | |
| 640 | + "id": 7, | |
| 641 | + "node_id": "RGVwbG95bWVudDphY21lL3dlYnNpdGUvNw", | |
| 642 | + "sha": "9fceb02d0ae598e95dc970b74767f19372d61af8", | |
| 643 | + "ref": "main", | |
| 644 | + "task": "deploy", | |
| 645 | + "payload": {}, | |
| 646 | + "original_environment": "production", | |
| 647 | + "environment": "production", | |
| 648 | + "description": "Deploy 1.4.0", | |
| 649 | + "creator": { "login": "diffy", "type": "User" }, | |
| 650 | + "created_at": "2026-10-13T09:30:00Z", | |
| 651 | + "updated_at": "2026-10-13T09:30:00Z", | |
| 652 | + "statuses_url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7/statuses", | |
| 653 | + "repository_url": "https://diffinery.com/api/v3/repos/acme/website", | |
| 654 | + "transient_environment": false, | |
| 655 | + "production_environment": true, | |
| 656 | + "performed_via_github_app": null | |
| 657 | +} | |
| 658 | +``` | |
| 659 | + | |
| 660 | +### Required contexts | |
| 661 | + | |
| 662 | +Before creating a deployment, Diffinery checks the commit's checks and commit statuses, as GitHub does: | |
| 663 | + | |
| 664 | +- **`required_contexts` left out:** every check and status context reported on the commit must have passed. Contexts reported only by agents aren't counted. | |
| 665 | +- **A list of names:** each named context must have passed. A name that nothing reported counts as not passed. | |
| 666 | +- **`[]`:** nothing is checked. | |
| 667 | + | |
| 668 | +A context passes when its result is `success`, `neutral` or `skipped`. When several reporters use the same name, the same rules as for required checks in branch protection apply: Diffinery Actions decides a check it reported, any failure fails, anything unfinished is pending, and reports from agents never count. See [Checks and commit statuses](/docs/collaboration/checks-and-statuses). | |
| 669 | + | |
| 670 | +If a context hasn't passed, the request fails with `409`: | |
| 671 | + | |
| 672 | +```json | |
| 673 | +{ | |
| 674 | + "message": "Conflict: Commit status checks failed for main.", | |
| 675 | + "errors": [ | |
| 676 | + { "contexts": [{ "context": "ci/build", "state": "pending" }], "resource": "Deployment", "field": "required_contexts" } | |
| 677 | + ], | |
| 678 | + "documentation_url": "https://docs.github.com/rest" | |
| 679 | +} | |
| 680 | +``` | |
| 681 | + | |
| 682 | +`state` is `failure`, `pending` or `missing`. Wait for the checks to finish, or pass `required_contexts` with only the contexts you care about. | |
| 683 | + | |
| 684 | +Deployments that workflow jobs create for `environment:` skip this check. | |
| 685 | + | |
| 686 | +### List, get and delete deployments | |
| 687 | + | |
| 688 | +```sh | |
| 689 | +# Newest first; filter by sha, ref, task or environment (exact match) | |
| 690 | +curl "https://diffinery.com/api/v3/repos/acme/website/deployments?environment=production&per_page=10" \ | |
| 691 | + -H "Authorization: Bearer dfy_..." | |
| 692 | + | |
| 693 | +curl https://diffinery.com/api/v3/repos/acme/website/deployments/7 \ | |
| 694 | + -H "Authorization: Bearer dfy_..." | |
| 695 | + | |
| 696 | +curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/deployments/7 \ | |
| 697 | + -H "Authorization: Bearer dfy_..." | |
| 698 | +``` | |
| 699 | + | |
| 700 | +- The list takes `per_page` (default 30, at most 100) and `page`, and sends a `Link` header. | |
| 701 | +- The `ref` filter compares the stored ref exactly. Deployments created by workflow jobs store the branch or tag name (`main`, `v1.4.0`), not `refs/heads/main`. | |
| 702 | +- A missing deployment returns `404 deployment not found`. | |
| 703 | +- Deleting needs the write role and returns `204 No Content`. A deployment whose latest status is `success` can't be deleted while other deployments to the same environment exist: `422 We cannot delete an active deployment unless it is the only deployment in a given environment.` Post an `inactive` status first. | |
| 704 | + | |
| 705 | +### Deployment statuses | |
| 706 | + | |
| 707 | +```sh | |
| 708 | +curl -X POST https://diffinery.com/api/v3/repos/acme/website/deployments/7/statuses \ | |
| 709 | + -H "Authorization: Bearer dfy_..." \ | |
| 710 | + -H "Content-Type: application/json" \ | |
| 711 | + -d '{"state": "success", "log_url": "https://ci.example.com/builds/812", "environment_url": "https://www.example.com", "description": "Deployed"}' | |
| 712 | +``` | |
| 713 | + | |
| 714 | +| Field | Description | | |
| 715 | +|---|---| | |
| 716 | +| `state` | Required: `error`, `failure`, `inactive`, `in_progress`, `queued`, `pending` or `success`. Anything else fails with `422 state must be one of error, failure, inactive, in_progress, queued, pending, success`. | | |
| 717 | +| `log_url` | Link to the deployment's output. `target_url` is accepted as an older name for it. | | |
| 718 | +| `environment_url` | Link to the deployed environment. | | |
| 719 | +| `description` | Up to 140 characters; longer text is cut. | | |
| 720 | +| `environment` | Moves the deployment to another environment (created if needed). The deployment's `original_environment` keeps the first name. | | |
| 721 | +| `auto_inactive` | Default `true`. With a `success` state, older deployments to the same environment whose latest status is `success` get an `inactive` status, except transient and production deployments. Deployments created after this one are left alone. Send `false` to keep them all. | | |
| 722 | + | |
| 723 | +URLs must start with `http://` or `https://` (`422 URLs must be http(s)`). The response is `201 Created`: | |
| 724 | + | |
| 725 | +```json | |
| 726 | +{ | |
| 727 | + "url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7/statuses/15", | |
| 728 | + "id": 15, | |
| 729 | + "node_id": "RGVwbG95bWVudFN0YXR1czphY21lL3dlYnNpdGUvMTU", | |
| 730 | + "state": "success", | |
| 731 | + "creator": { "login": "diffy", "type": "User" }, | |
| 732 | + "description": "Deployed", | |
| 733 | + "environment": "production", | |
| 734 | + "target_url": "https://ci.example.com/builds/812", | |
| 735 | + "log_url": "https://ci.example.com/builds/812", | |
| 736 | + "environment_url": "https://www.example.com", | |
| 737 | + "created_at": "2026-10-13T09:34:10Z", | |
| 738 | + "updated_at": "2026-10-13T09:34:10Z", | |
| 739 | + "deployment_url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7", | |
| 740 | + "repository_url": "https://diffinery.com/api/v3/repos/acme/website", | |
| 741 | + "performed_via_github_app": null | |
| 742 | +} | |
| 743 | +``` | |
| 744 | + | |
| 745 | +List a deployment's statuses, newest first, with `GET /api/v3/repos/{owner}/{repo}/deployments/{id}/statuses` (`per_page`, `page`), and get one with `GET /api/v3/repos/{owner}/{repo}/deployments/{id}/statuses/{status_id}` (`404 deployment status not found` if it doesn't exist). | |
| 746 | + | |
| 747 | +## Endpoint summary | |
| 748 | + | |
| 749 | +All paths are under `https://diffinery.com/api/v3`. Every endpoint is also in the [API reference](/docs/api). | |
| 750 | + | |
| 751 | +| Method and path | Needs | | |
| 752 | +|---|---| | |
| 753 | +| `GET /repos/{owner}/{repo}/deployments` | read | | |
| 754 | +| `POST /repos/{owner}/{repo}/deployments` | write | | |
| 755 | +| `GET /repos/{owner}/{repo}/deployments/{id}` | read | | |
| 756 | +| `DELETE /repos/{owner}/{repo}/deployments/{id}` | write | | |
| 757 | +| `GET /repos/{owner}/{repo}/deployments/{id}/statuses` | read | | |
| 758 | +| `POST /repos/{owner}/{repo}/deployments/{id}/statuses` | write | | |
| 759 | +| `GET /repos/{owner}/{repo}/deployments/{id}/statuses/{status_id}` | read | | |
| 760 | +| `GET /repos/{owner}/{repo}/environments` | read | | |
| 761 | +| `GET /repos/{owner}/{repo}/environments/{env}` | read | | |
| 762 | +| `PUT /repos/{owner}/{repo}/environments/{env}` | admin, people only | | |
| 763 | +| `DELETE /repos/{owner}/{repo}/environments/{env}` | admin, people only | | |
| 764 | +| `GET /repos/{owner}/{repo}/environments/{env}/deployment-branch-policies` | read | | |
| 765 | +| `POST /repos/{owner}/{repo}/environments/{env}/deployment-branch-policies` | admin, people only | | |
| 766 | +| `DELETE /repos/{owner}/{repo}/environments/{env}/deployment-branch-policies/{id}` | admin, people only | | |
| 767 | +| `GET /repos/{owner}/{repo}/environments/{env}/secrets` and `.../secrets/{name}` | admin | | |
| 768 | +| `PUT` and `DELETE /repos/{owner}/{repo}/environments/{env}/secrets/{name}` | admin, people only | | |
| 769 | +| `GET /repos/{owner}/{repo}/environments/{env}/variables` and `.../variables/{name}` | write | | |
| 770 | +| `POST /repos/{owner}/{repo}/environments/{env}/variables` | admin, people only | | |
| 771 | +| `PATCH` and `DELETE /repos/{owner}/{repo}/environments/{env}/variables/{name}` | admin, people only | | |
| 772 | +| `GET /repos/{owner}/{repo}/actions/runs/{run_id}/pending_deployments` | read | | |
| 773 | +| `POST /repos/{owner}/{repo}/actions/runs/{run_id}/pending_deployments` | a listed reviewer (people only, `write` token) | | |
| 774 | +| `GET /repos/{owner}/{repo}/actions/runs/{run_id}/approvals` | read | | |
| 775 | + | |
| 776 | +The token's scope caps the role: a `read` token only reads (any other request gets `403 this token needs the write scope`), a `write` token also writes, and only an `admin` token reaches the admin endpoints. Requests without a token can read the environments and deployments of a public repository, but any change gets `401 authentication required`. On an archived repository, every request other than a read fails with `403 repository is archived (read-only)`. | |
| 777 | + | |
| 778 | +## Webhooks | |
| 779 | + | |
| 780 | +Repository webhooks receive three events, with the GitHub event names in the `X-GitHub-Event` header. In the web UI's **Add a webhook** form, choose **Everything** to receive them; with the API, list `deployment`, `deployment_status` and `deployment_review` in the webhook's `events`. See [Webhooks](/docs/repositories/webhooks). | |
| 781 | + | |
| 782 | +### deployment | |
| 783 | + | |
| 784 | +Sent when a deployment is created with the REST API: | |
| 785 | + | |
| 786 | +```json | |
| 787 | +{ | |
| 788 | + "action": "created", | |
| 789 | + "deployment": { | |
| 790 | + "id": 7, | |
| 791 | + "sha": "9fceb02d0ae598e95dc970b74767f19372d61af8", | |
| 792 | + "ref": "main", | |
| 793 | + "task": "deploy", | |
| 794 | + "environment": "production", | |
| 795 | + "description": "Deploy 1.4.0", | |
| 796 | + "payload": {}, | |
| 797 | + "created_at": "2026-10-13T09:30:00.000Z" | |
| 798 | + }, | |
| 799 | + "repository": { "full_name": "acme/website" }, | |
| 800 | + "sender": { "login": "diffy", "type": "User" } | |
| 801 | +} | |
| 802 | +``` | |
| 803 | + | |
| 804 | +Deployments that workflow jobs create for `environment:` send this event too, when the job starts. Their `description` is `null`, `task` is `deploy` and `payload` is `{}`. | |
| 805 | + | |
| 806 | +### deployment_status | |
| 807 | + | |
| 808 | +Sent for every status created with the REST API or by a workflow job (the `in_progress` status when the job starts and the final status when it ends): | |
| 809 | + | |
| 810 | +```json | |
| 811 | +{ | |
| 812 | + "action": "created", | |
| 813 | + "deployment_status": { | |
| 814 | + "id": 15, | |
| 815 | + "state": "success", | |
| 816 | + "description": "", | |
| 817 | + "environment": "production", | |
| 818 | + "environment_url": "https://www.example.com", | |
| 819 | + "log_url": "https://diffinery.com/acme/website/actions/runs/01J9Z.../job/01J9Z...", | |
| 820 | + "created_at": "2026-10-13T09:34:10.000Z" | |
| 821 | + }, | |
| 822 | + "deployment": { "id": 7, "sha": "9fceb02d0ae598e95dc970b74767f19372d61af8", "ref": "main", "task": "deploy", "environment": "production" }, | |
| 823 | + "repository": { "full_name": "acme/website" }, | |
| 824 | + "sender": { "login": "diffy", "type": "User" } | |
| 825 | +} | |
| 826 | +``` | |
| 827 | + | |
| 828 | +The `inactive` statuses that `auto_inactive` adds to older deployments don't send events. The `repository` and `sender` objects have more fields than shown; see [Webhook events](/docs/reference/webhook-events). | |
| 829 | + | |
| 830 | +### deployment_review | |
| 831 | + | |
| 832 | +Sent when a run starts waiting on an environment's required reviewers (`requested`), and when a reviewer decides (`approved` or `rejected`). The payload has `environment`, `environment_id`, `comment`, `workflow_run` (`id`, `run_number`, `name`, `display_title`, `head_sha`, `head_branch`, `html_url`), `reviewers`, and `requestor` (requested) or `approver` (approved and rejected). | |
| 833 | + | |
| 834 | +Workflows with `on: deployment` or `on: deployment_status` are accepted but never started. To run something after a deployment, use `needs:` on a later job, or a webhook. | |
| 835 | + | |
| 836 | +## Agents and environments | |
| 837 | + | |
| 838 | +Environments guard deployment credentials, so agents are limited: | |
| 839 | + | |
| 840 | +- Agents can't create, change or delete environments or their branch policies, whatever their role. An agent with the write role gets `403 admin access required`; one that was given the admin role gets `403 only repository admins (people, not agents) can manage environments`. | |
| 841 | +- Agents can't set or delete environment secrets: an agent with the admin role gets `403 agents cannot manage secrets`. | |
| 842 | +- Agents can't create, change or delete environment variables: an agent with the admin role gets `403 agents cannot manage variables`. | |
| 843 | +- Runs whose code came from an agent only get environment secrets from environments set to protected branches only, and never on pull request refs (see [Who receives environment secrets](#who-receives-environment-secrets)). | |
| 844 | +- Tags that an agent created or moved never match an environment's tag patterns. | |
| 845 | +- Agents with write access can create deployments and deployment statuses with the API (with a `write` token), and can read environments and environment variables. | |
| 846 | + | |
| 847 | +Together with branch protection (agents can't push to protected branches unless a rule allows it, and pull requests opened by agents need a human approval), this means an agent can't get production credentials into code that no person reviewed. The exception is a branch whose rule has **Let agents' auto-merges land without an approving review** on (see [Waiving approvals for agents](/docs/repositories/branch-protection#waiving-approvals-for-agents)): there an agent's pull request lands on green required checks alone, and the runs that follow on that branch receive the secrets of environments limited to protected branches. Turn that waiver on only where the required checks are the review you rely on. See [Agents](/docs/agents/overview). | |
| 848 | + | |
| 849 | +## Audit log | |
| 850 | + | |
| 851 | +Changes to environments made with the API are recorded in the audit log, without secret values: | |
| 852 | + | |
| 853 | +| Action | When | | |
| 854 | +|---|---| | |
| 855 | +| `environment.update` | An environment is created or updated with `PUT` or in Settings (with the new branch policy, reviewers, wait timer and self-review setting). | | |
| 856 | +| `environment.delete` | An environment is deleted. | | |
| 857 | +| `environment.branch_policy.create` | A branch or tag pattern is added. | | |
| 858 | +| `environment.branch_policy.delete` | A pattern is removed. | | |
| 859 | +| `environment.secret.set` | An environment secret is created or replaced (name only). | | |
| 860 | +| `environment.secret.delete` | An environment secret is deleted. | | |
| 861 | +| `deployment.approve`, `deployment.reject` | A required reviewer approves or rejects a run's environments (with the comment). | | |
| 862 | + | |
| 863 | +Environment variable changes aren't recorded. See [Audit log](/docs/security/audit-log). | |
| 864 | + | |
| 865 | +## What isn't supported | |
| 866 | + | |
| 867 | +- **Custom deployment protection rules** (GitHub Apps that gate deployments) and **team reviewers**. Diffinery has no teams; list people. | |
| 868 | +- **Admin bypass.** Nobody skips an environment's rules. | |
| 869 | +- **Web UI for branch patterns, environment secrets and variables.** Settings → Environments edits the protection rules; the rest is managed with the REST API. | |
| 870 | +- **Reading or updating a single branch pattern.** You can list, add and delete patterns, but not `GET` or `PUT` one by id. | |
| 871 | +- **Sealed-box secrets.** Send `value`, not `encrypted_value`. | |
| 872 | +- **Organization-level environments and environment secrets.** Environments belong to one repository. | |
| 873 | +- **`on: deployment` and `on: deployment_status` workflow triggers.** Accepted but never started. | |
| 874 | + | |
| 875 | +## Differences from GitHub | |
| 876 | + | |
| 877 | +| Area | GitHub | Diffinery | | |
| 878 | +|---|---|---| | |
| 879 | +| Managing environments | Web UI and API | Protection rules, deployment branches, patterns and secrets in Settings → Environments and the API; variables with the API | | |
| 880 | +| Required reviewers | People and teams | People only, never agents; an agent or a job token can't approve | | |
| 881 | +| Prevent self-review | Covers whoever triggered the run | Also covers earlier attempts' starters and the owner of an agent that started it | | |
| 882 | +| Admin bypass | Admins can bypass protection rules | Nobody bypasses them; `can_admins_bypass` is `false` | | |
| 883 | +| Default branch policy | All branches | All refs, until the first secret is added; then protected branches only | | |
| 884 | +| Protected branches | Branches with protection rules | Branches matched by a branch protection rule; the default branch counts only when a rule matches it | | |
| 885 | +| Pull request runs | Fork pull requests get no secrets | No `pull_request` run gets environment secrets | | |
| 886 | +| Agents | No equivalent | Agents can't manage environments, get environment secrets only under "protected branches only", and their tags never match tag patterns | | |
| 887 | +| `environment.url` | Can use `steps` outputs | `steps` references are ignored | | |
| 888 | +| Deployment `auto_merge` | Merges the default branch into the ref | Ignored | | |
| 889 | +| Branch patterns | `fnmatch` syntax with character classes | `*`, `**` and `?` only, unlike workflow filters | | |
| 890 | + | |
| 891 | +## Troubleshooting | |
| 892 | + | |
| 893 | +| Problem | Cause and fix | | |
| 894 | +|---|---| | |
| 895 | +| `<ref> is not allowed to deploy to <env> due to environment protection rules.` | The environment's branch policy doesn't allow the run's ref. Check the policy with `GET .../environments/<env>`: with protected branches only, add a branch protection rule for the branch; with custom patterns, add a matching pattern. A tag last pushed by an agent never matches. | | |
| 896 | +| The deploy job fails on a pull request | Pull request refs never pass a branch policy. Add `if: github.event_name == 'push'` (or a branch check) to the deploy job. | | |
| 897 | +| `invalid environment name ''` | `environment.name` evaluated to an empty string, for example because it uses `steps`, or a `needs` output that's empty. Use `inputs`, `vars`, `matrix`, `needs` or a literal. | | |
| 898 | +| An environment secret is empty in the job | The run is on a pull request ref, or the run's code came from an agent and the environment isn't set to protected branches only, or the name is wrong. Variables are still set. | | |
| 899 | +| A new environment suddenly refuses a branch | Adding its first secret set the policy to protected branches only. Add a branch protection rule, or switch to custom patterns. | | |
| 900 | +| `403 only repository admins (people, not agents) can manage environments` | Use a person's token with the `admin` scope and the admin role on the repository. | | |
| 901 | +| `403 agents cannot manage secrets` | Use a person's token. | | |
| 902 | +| `403 agents cannot manage variables` | Agents can't change environment variables. Use a person's token. | | |
| 903 | +| `403 this token needs the write scope` | The token has only the `read` scope. Use a `write` token for deployments and statuses, or an `admin` token for environment settings. | | |
| 904 | +| A job stays **Waiting for approval** | The environment has required reviewers: one of them approves on the run page or with `pending_deployments`. With a wait timer, the job starts when it ends. | | |
| 905 | +| `422 reviewers[0]: no such person with access to this repository` | GitHub's numeric user ids don't carry over. Use `{"type": "User", "login": "..."}` with a Diffinery login. | | |
| 906 | +| `422 set exactly one of protected_branches and custom_branch_policies` | Set one to `true` and the other to `false`, or send `null` to remove the policy. | | |
| 907 | +| `422 enable custom_branch_policies on the environment first` | Set `custom_branch_policies: true` with `PUT` before adding patterns. | | |
| 908 | +| `409 Conflict: Commit status checks failed for <ref>.` | A context on the commit hasn't passed. Wait for it, or pass `required_contexts` (`[]` to skip the check). | | |
| 909 | +| `422 We cannot delete an active deployment unless it is the only deployment in a given environment.` | Post an `inactive` status to the deployment first. | | |
| 910 | +| `403 Resource not accessible by integration` or `403 write access required` when a workflow step creates a deployment | The job lacks `deployments: write`. Add it to the job's `permissions:`, or use `environment:` on the job. | | |
| 911 | +| `403 admin access required` | Managing environments, branch patterns, environment secrets and variable changes needs the admin role and an `admin`-scoped token. | | |
| 912 | +| The deployment has no environment URL | `environment.url` used `steps`, or wasn't an `http(s)` URL. | |
addedapps/diffinery/docs/actions/events.md+684−0View file
| @@ -1,0 +1,684 @@ | ||
| 1 | +--- | |
| 2 | +title: Events that trigger workflows | |
| 3 | +description: Every event that can start a Diffinery workflow, its activity types and filters, the commit and ref it runs on, and its github.event payload. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +The `on:` section of a workflow says which events start it. Diffinery supports the GitHub Actions events that matter for CI and automation: pushes, pull requests, merge queue groups, manual dispatch, schedules, other workflows' runs, published releases, external `repository_dispatch` events and reusable workflow calls. This page lists each one with its activity types, filters, the `GITHUB_SHA` and `GITHUB_REF` its runs get, and its `github.event` payload. | |
| 7 | + | |
| 8 | +## Supported events | |
| 9 | + | |
| 10 | +| Event | Fires when | Activity types | Filters | | |
| 11 | +| --- | --- | --- | --- | | |
| 12 | +| [`push`](#push) | A branch or tag is created or updated | | `branches`, `branches-ignore`, `tags`, `tags-ignore`, `paths`, `paths-ignore` | | |
| 13 | +| [`pull_request`](#pull_request) | A pull request is opened, updated, edited, reopened, closed, marked ready for review or converted to a draft | `opened`, `synchronize`, `reopened`, `closed`, `edited`, `ready_for_review`, `converted_to_draft` | `types`, `branches`, `branches-ignore`, `paths`, `paths-ignore` | | |
| 14 | +| [`pull_request_target`](#pull_request_target) | Same as `pull_request`, but runs the base branch's workflow | Same as `pull_request` | Same as `pull_request` | | |
| 15 | +| [`merge_group`](#merge_group) | The merge queue builds a merge group | `checks_requested` | `types`, `branches`, `branches-ignore` | | |
| 16 | +| [`workflow_dispatch`](#workflow_dispatch) | Someone starts the workflow from the UI, CLI, API or MCP | | `inputs` | | |
| 17 | +| [`schedule`](#schedule) | A cron schedule is due | | `cron` | | |
| 18 | +| [`workflow_run`](#workflow_run) | A run of another named workflow is requested or completes | `requested`, `completed` | `workflows`, `types`, `branches`, `branches-ignore` | | |
| 19 | +| [`release`](#release) | A release is published, unpublished, edited or deleted | `published`, `unpublished`, `created`, `edited`, `deleted`, `prereleased`, `released` | `types` | | |
| 20 | +| [`repository_dispatch`](#repository_dispatch) | Someone calls `POST /repos/{owner}/{repo}/dispatches` | Any `event_type` you choose | `types` | | |
| 21 | +| [`workflow_call`](#workflow_call) | Another workflow calls this one as a reusable workflow | | `inputs`, `outputs`, `secrets` | | |
| 22 | + | |
| 23 | +### Other GitHub events | |
| 24 | + | |
| 25 | +The other GitHub event names (`issues`, `issue_comment`, `create`, `delete`, `pull_request_review`, `check_run`, `status`, `deployment`, `deployment_status`, `label`, `watch` and the rest of GitHub's list) are accepted in `on:` without an error or warning, so workflows that list them still parse, but Diffinery never starts a run for them. A name that isn't a GitHub event at all gives the warning `unknown event '<name>'` in `diffinery workflow validate`. | |
| 26 | + | |
| 27 | +## Writing on | |
| 28 | + | |
| 29 | +```yaml | |
| 30 | +on: push # one event, no configuration | |
| 31 | +``` | |
| 32 | + | |
| 33 | +```yaml | |
| 34 | +on: [push, pull_request] # several events, no configuration | |
| 35 | +``` | |
| 36 | + | |
| 37 | +```yaml | |
| 38 | +on: # events with configuration | |
| 39 | + push: | |
| 40 | + branches: [main] | |
| 41 | + tags: ["v*"] | |
| 42 | + pull_request: | |
| 43 | + types: [opened, synchronize, reopened, closed] | |
| 44 | + paths: ["src/**"] | |
| 45 | + workflow_dispatch: | |
| 46 | +``` | |
| 47 | + | |
| 48 | +An event's configuration must be a mapping (`on.<event> must be a mapping`), except `schedule`, which is a list. Each workflow file whose `on:` matches an event gets one run for it. | |
| 49 | + | |
| 50 | +## Filter patterns | |
| 51 | + | |
| 52 | +Branch, tag and path filters use GitHub's filter pattern syntax: | |
| 53 | + | |
| 54 | +| Pattern | Matches | | |
| 55 | +| --- | --- | | |
| 56 | +| `*` | Any characters except `/`. `feature/*` matches `feature/login` but not `feature/a/b`. | | |
| 57 | +| `**` | Any characters, including `/`. `feature/**` matches `feature/a/b`. | | |
| 58 | +| `**/` at the start | Any directories, or none. `**/README.md` matches `README.md` and `docs/README.md`. | | |
| 59 | +| `?` | Zero or one of the preceding character. `v1?` matches `v` and `v1`. | | |
| 60 | +| `+` | One or more of the preceding character. `v1+` matches `v1` and `v111`. | | |
| 61 | +| `[abc]`, `[0-9]` | One character from the set or range. | | |
| 62 | +| `\` | Escapes the next character, for example `\*` for a literal `*`. | | |
| 63 | +| `!pattern` | In a list, excludes what earlier patterns matched. | | |
| 64 | + | |
| 65 | +Patterns match the whole name: a branch name without `refs/heads/`, a tag name without `refs/tags/`, or a file path relative to the repository root. Quote patterns that start with `*`, `!` or `[` in YAML. | |
| 66 | + | |
| 67 | +A list is read in order, and the last pattern that matches decides: a value is included when it matches a positive pattern and no later `!` pattern excludes it. A `!` pattern can only exclude what an earlier pattern included, so a list of only `!` patterns matches nothing. | |
| 68 | + | |
| 69 | +```yaml | |
| 70 | +on: | |
| 71 | + push: | |
| 72 | + branches: | |
| 73 | + - "releases/**" | |
| 74 | + - "!releases/**-alpha" | |
| 75 | +``` | |
| 76 | + | |
| 77 | +This runs for `releases/10` and `releases/beta/mona`, but not `releases/10-alpha`. | |
| 78 | + | |
| 79 | +### Include and ignore filters | |
| 80 | + | |
| 81 | +- `branches` runs the workflow only for matching branches; `branches-ignore` runs it for every branch except matching ones. The same goes for `tags` and `tags-ignore`, and `paths` and `paths-ignore`. | |
| 82 | +- You can't use a filter and its `-ignore` form for the same event. The file fails with `'branches' and 'branches-ignore' cannot be used together for the same event` (or the same message for `tags` or `paths`). Use `!` patterns in the positive form instead. | |
| 83 | + | |
| 84 | +### Path filters | |
| 85 | + | |
| 86 | +- With `paths`, the workflow runs when at least one changed file matches the list. | |
| 87 | +- With `paths-ignore`, the workflow runs unless every changed file matches the list. | |
| 88 | +- For a push, the changed files are the difference between the commit before the push and the pushed commit. When the push creates a new branch, or the previous commit isn't known, Diffinery can't compute the change and path filters pass. (GitHub compares a new branch with the default branch instead.) | |
| 89 | +- For a pull request, the changed files are those the pull request changes, compared with the merge base of the head and the base branch. When a merged pull request is closed, path filters pass. | |
| 90 | +- Path filters are never evaluated for tag pushes. | |
| 91 | +- When branch or tag filters and path filters are both set, both must pass. | |
| 92 | + | |
| 93 | +## push | |
| 94 | + | |
| 95 | +Runs when you push to a branch or tag, including pushes that create a branch or tag, force pushes, pushes by agents, and merges made through the web UI, CLI, API or merge queue. Deleting a branch or tag doesn't start runs. | |
| 96 | + | |
| 97 | +```yaml | |
| 98 | +on: | |
| 99 | + push: | |
| 100 | + branches: [main, "release/**"] | |
| 101 | + tags: ["v*.*.*"] | |
| 102 | + paths-ignore: ["docs/**", "**.md"] | |
| 103 | +``` | |
| 104 | + | |
| 105 | +How `branches` and `tags` combine, as on GitHub: | |
| 106 | + | |
| 107 | +| Filters set | Branch pushes | Tag pushes | | |
| 108 | +| --- | --- | --- | | |
| 109 | +| None | Run | Run | | |
| 110 | +| Only `branches` or `branches-ignore` | Run when the branch matches | Don't run | | |
| 111 | +| Only `tags` or `tags-ignore` | Don't run | Run when the tag matches | | |
| 112 | +| Both kinds | Run when the branch matches | Run when the tag matches | | |
| 113 | +| Only `paths` or `paths-ignore` | Run when the paths match | Run (paths aren't checked for tags) | | |
| 114 | + | |
| 115 | +The workflow file is read from the pushed commit, so a push that changes a workflow runs the new version. | |
| 116 | + | |
| 117 | +When a merge queue builds a merge group, it pushes a branch named `gh-readonly-queue/<base>/pr-<n>-<head sha>`, and `push` workflows run on it as on GitHub. Add `branches-ignore: ["gh-readonly-queue/**"]` (or list only the branches you want) to avoid running twice. | |
| 118 | + | |
| 119 | +| Variable | Value | | |
| 120 | +| --- | --- | | |
| 121 | +| `GITHUB_SHA` | The pushed commit | | |
| 122 | +| `GITHUB_REF` | The pushed ref, `refs/heads/<branch>` or `refs/tags/<tag>` | | |
| 123 | + | |
| 124 | +Payload (`github.event`): | |
| 125 | + | |
| 126 | +| Field | Value | | |
| 127 | +| --- | --- | | |
| 128 | +| `ref` | The pushed ref | | |
| 129 | +| `before` | The commit the ref pointed to before the push (`0000000000000000000000000000000000000000` for a new ref) | | |
| 130 | +| `after` | The pushed commit | | |
| 131 | +| `created`, `deleted`, `forced` | Whether the push created the ref, deleted it (always `false` for runs) or was a force push | | |
| 132 | +| `head_commit` | `id`, `message`, `timestamp` (committer time) and `author` (`name`, `email`) of the pushed commit | | |
| 133 | +| `commits` | A list with the pushed commit only (`id`, `message`). GitHub lists up to 20 commits. | | |
| 134 | +| `pusher` | `name`: the login of the account that pushed, and `email`: `null` | | |
| 135 | +| `sender` | See [The sender object](#the-sender-object) | | |
| 136 | +| `repository` | See [The repository object](#the-repository-object) | | |
| 137 | + | |
| 138 | +## pull_request | |
| 139 | + | |
| 140 | +Runs for activity on a pull request, using the workflow and code of the pull request's test merge commit: the head branch merged into the current base branch, as on GitHub. | |
| 141 | + | |
| 142 | +```yaml | |
| 143 | +on: | |
| 144 | + pull_request: | |
| 145 | + types: [opened, synchronize, reopened] | |
| 146 | + branches: [main] | |
| 147 | + paths: ["src/**", "package.json"] | |
| 148 | +``` | |
| 149 | + | |
| 150 | +| Type | Fires when | | |
| 151 | +| --- | --- | | |
| 152 | +| `opened` | A pull request is created (drafts included) | | |
| 153 | +| `synchronize` | New commits are pushed to the head branch | | |
| 154 | +| `reopened` | A closed pull request is reopened | | |
| 155 | +| `closed` | A pull request is closed or merged. Check `github.event.pull_request.merged` to tell them apart. | | |
| 156 | +| `edited` | The title or description changed. Not for label changes, and not when the text saved is the same. | | |
| 157 | +| `ready_for_review` | A draft is marked ready for review: **Ready for review** in the web UI, `diffinery pr ready`, or `PATCH /repos/{owner}/{repo}/pulls/{number}` with `"draft": false` | | |
| 158 | +| `converted_to_draft` | An open pull request is turned back into a draft: `diffinery pr ready --undo`, or `PATCH` with `"draft": true` | | |
| 159 | + | |
| 160 | +Without `types`, the workflow runs for `opened`, `synchronize` and `reopened`, as on GitHub. GitHub's other types (`labeled`, `assigned`, `review_requested`, `locked` and so on) are accepted in `types` but never fire on Diffinery. | |
| 161 | + | |
| 162 | +A common use is to skip expensive jobs while a pull request is a draft and run them once it's ready: | |
| 163 | + | |
| 164 | +```yaml | |
| 165 | +on: | |
| 166 | + pull_request: | |
| 167 | + types: [opened, synchronize, reopened, ready_for_review] | |
| 168 | +jobs: | |
| 169 | + e2e: | |
| 170 | + if: ${{ !github.event.pull_request.draft }} | |
| 171 | + runs-on: ubuntu-latest | |
| 172 | + steps: | |
| 173 | + - uses: actions/checkout@v4 | |
| 174 | + - run: npm ci && npm run e2e | |
| 175 | +``` | |
| 176 | + | |
| 177 | +For `edited`, the payload has no `changes` object with the old title or body (GitHub sends one). | |
| 178 | + | |
| 179 | +`branches` and `branches-ignore` filter on the pull request's base branch. | |
| 180 | + | |
| 181 | +- The run checks out and runs `refs/pull/<n>/merge`. You can fetch that ref and `refs/pull/<n>/head` with git, but not push to them. | |
| 182 | +- While a pull request conflicts with its base, there is no test merge commit and its `pull_request` workflows don't run. `pull_request_target` workflows still do. | |
| 183 | +- For `closed` on a merged pull request, the run uses the merge commit instead. | |
| 184 | +- The run's checks are reported on the pull request's head commit, so they appear on the pull request. | |
| 185 | +- Every pull request comes from a branch of the same repository, so `pull_request` runs get the repository's secrets, like `push` runs. | |
| 186 | + | |
| 187 | +| Variable | Value | | |
| 188 | +| --- | --- | | |
| 189 | +| `GITHUB_SHA` | The test merge commit (for a merged pull request's `closed` event, the merge commit) | | |
| 190 | +| `GITHUB_REF` | `refs/pull/<n>/merge` | | |
| 191 | +| `GITHUB_REF_NAME` | `<n>/merge` | | |
| 192 | +| `GITHUB_HEAD_REF` | The head branch | | |
| 193 | +| `GITHUB_BASE_REF` | The base branch | | |
| 194 | + | |
| 195 | +Payload (`github.event`): | |
| 196 | + | |
| 197 | +| Field | Value | | |
| 198 | +| --- | --- | | |
| 199 | +| `action` | The activity type | | |
| 200 | +| `number` | The pull request number | | |
| 201 | +| `pull_request.number`, `pull_request.id` | The pull request number (both) | | |
| 202 | +| `pull_request.node_id` | An opaque ID | | |
| 203 | +| `pull_request.state` | `open` or `closed` | | |
| 204 | +| `pull_request.locked` | `false` | | |
| 205 | +| `pull_request.title`, `pull_request.body` | Title and description | | |
| 206 | +| `pull_request.draft`, `pull_request.merged` | Booleans | | |
| 207 | +| `pull_request.merged_at` | When it merged, or `null` | | |
| 208 | +| `pull_request.merged_by` | `{ "login": ... }` or `null` | | |
| 209 | +| `pull_request.merge_commit_sha` | The merge commit once merged, otherwise the test merge commit (or `null` while it conflicts) | | |
| 210 | +| `pull_request.user` | The author, as a user object (see [The sender object](#the-sender-object)) | | |
| 211 | +| `pull_request.labels` | `[{ "name": ... }]` | | |
| 212 | +| `pull_request.assignees` | `[{ "login": ... }]` | | |
| 213 | +| `pull_request.requested_reviewers` | `[]` | | |
| 214 | +| `pull_request.author_association` | `MEMBER` | | |
| 215 | +| `pull_request.head` | `label` (`<owner>:<branch>`), `ref` (branch), `sha` (head commit), `user` (the repository owner) and `repo` (a repository object) | | |
| 216 | +| `pull_request.base` | The same fields for the base branch | | |
| 217 | +| `pull_request.html_url` | `https://diffinery.com/acme/website/pull/<n>` | | |
| 218 | +| `pull_request.url` | `https://diffinery.com/api/v3/repos/acme/website/pulls/<n>` | | |
| 219 | +| `pull_request.diff_url`, `pull_request.patch_url` | `https://diffinery.com/acme/website/pull/<n>.diff` and `.patch` | | |
| 220 | +| `pull_request.issue_url`, `pull_request.commits_url`, `pull_request.review_comments_url`, `pull_request.comments_url` | API URLs, as on GitHub | | |
| 221 | +| `repository` | See [The repository object](#the-repository-object) | | |
| 222 | +| `organization` | For repositories owned by an organization; see [The repository object](#the-repository-object) | | |
| 223 | +| `sender` | Whoever caused the event; see [The sender object](#the-sender-object) | | |
| 224 | + | |
| 225 | +## pull_request_target | |
| 226 | + | |
| 227 | +Same activity types, filters and payload as `pull_request`, but the run uses the base branch's own workflow file and code: the base branch's current commit. A pull request therefore can't change what runs with the repository's secrets. Check out the pull request's code (for example with `ref: ${{ github.event.pull_request.head.sha }}`) only if you trust it. | |
| 228 | + | |
| 229 | +```yaml | |
| 230 | +on: | |
| 231 | + pull_request_target: | |
| 232 | + types: [opened, reopened] | |
| 233 | +jobs: | |
| 234 | + welcome: | |
| 235 | + runs-on: ubuntu-latest | |
| 236 | + steps: | |
| 237 | + - run: echo "Thanks for pull request #${{ github.event.number }}, ${{ github.event.pull_request.user.login }}" | |
| 238 | +``` | |
| 239 | + | |
| 240 | +Unlike `pull_request`, it also runs while the pull request conflicts with its base. Checks are reported on the pull request's head commit. | |
| 241 | + | |
| 242 | +| Variable | Value | | |
| 243 | +| --- | --- | | |
| 244 | +| `GITHUB_SHA` | The base branch's current commit | | |
| 245 | +| `GITHUB_REF` | `refs/heads/<base branch>` | | |
| 246 | +| `GITHUB_HEAD_REF`, `GITHUB_BASE_REF` | The head and base branches | | |
| 247 | + | |
| 248 | +## merge_group | |
| 249 | + | |
| 250 | +Runs when the merge queue builds a merge group for a pull request: a commit holding the base branch, every pull request ahead in the queue, and this pull request's changes. The queue merges the pull request once the group's required checks pass. See [Merge queue](/docs/collaboration/merge-queue). | |
| 251 | + | |
| 252 | +```yaml | |
| 253 | +on: | |
| 254 | + pull_request: | |
| 255 | + merge_group: | |
| 256 | + types: [checks_requested] | |
| 257 | + branches: [main] | |
| 258 | +``` | |
| 259 | + | |
| 260 | +- The only activity type is `checks_requested`, which is also the default. | |
| 261 | +- `branches` and `branches-ignore` filter on the queue's base branch. | |
| 262 | +- The queue reads the required checks from the merge group commit, so the workflows that produce them must run on it: list `merge_group` next to `pull_request`. (A `push` workflow without branch filters also runs there.) If a required check never reports, the pull request leaves the queue when the queue's check timeout expires. | |
| 263 | +- `push` workflows also run on the merge group branch; see [push](#push). | |
| 264 | +- A merge group commit with an invalid workflow file gets an `Invalid workflow file` run, like a push. | |
| 265 | + | |
| 266 | +| Variable | Value | | |
| 267 | +| --- | --- | | |
| 268 | +| `GITHUB_SHA` | The merge group commit | | |
| 269 | +| `GITHUB_REF` | `refs/heads/gh-readonly-queue/<base>/pr-<n>-<head sha>` | | |
| 270 | + | |
| 271 | +Payload (`github.event`): | |
| 272 | + | |
| 273 | +| Field | Value | | |
| 274 | +| --- | --- | | |
| 275 | +| `action` | `checks_requested` | | |
| 276 | +| `merge_group.head_sha` | The merge group commit | | |
| 277 | +| `merge_group.head_ref` | The merge group ref | | |
| 278 | +| `merge_group.base_sha` | The commit the group was built on | | |
| 279 | +| `merge_group.base_ref` | `refs/heads/<base>` | | |
| 280 | +| `merge_group.head_commit` | `id`, `tree_id`, `message`, `timestamp`, `author` and `committer` (`name`, `email`) | | |
| 281 | +| `repository`, `sender` | As for other events | | |
| 282 | + | |
| 283 | +The run's title is `Merge group for #<n>: <pull request title>`. | |
| 284 | + | |
| 285 | +## workflow_dispatch | |
| 286 | + | |
| 287 | +Lets people and agents with write access start the workflow on demand, on a branch or tag they choose, with typed inputs. How to start a run from the web UI, the CLI, the API and MCP is in [Actions overview](/docs/actions/overview#start-a-workflow-manually). | |
| 288 | + | |
| 289 | +```yaml | |
| 290 | +on: | |
| 291 | + workflow_dispatch: | |
| 292 | + inputs: | |
| 293 | + environment: | |
| 294 | + description: Target environment | |
| 295 | + type: choice | |
| 296 | + options: [staging, production] | |
| 297 | + default: staging | |
| 298 | + version: | |
| 299 | + description: Version to deploy | |
| 300 | + required: true | |
| 301 | + replicas: | |
| 302 | + type: number | |
| 303 | + default: 2 | |
| 304 | + dry-run: | |
| 305 | + type: boolean | |
| 306 | + default: false | |
| 307 | + target: | |
| 308 | + type: environment | |
| 309 | +``` | |
| 310 | + | |
| 311 | +Each input can have: | |
| 312 | + | |
| 313 | +| Key | Meaning | | |
| 314 | +| --- | --- | | |
| 315 | +| `description` | Label in the **Run workflow** form (the input's name is shown under it). | | |
| 316 | +| `required` | `true` makes the input mandatory: an empty or missing value without a default is rejected with `input '<name>' is required`. Must be a boolean. | | |
| 317 | +| `default` | Used when no value is given. | | |
| 318 | +| `type` | `string` (the default), `boolean`, `number`, `choice` or `environment`. Another value fails with `input '<name>': invalid type '<type>' (expected one of string, boolean, number, choice, environment)`. | | |
| 319 | +| `options` | For `choice`: the allowed values. A `choice` input without options fails with `input '<name>' of type 'choice' must define 'options'`. A value outside the list is rejected with `input '<name>' must be one of <options>`. | | |
| 320 | +| `deprecationMessage` | Accepted and ignored. | | |
| 321 | + | |
| 322 | +Values are converted by type: `boolean` inputs become `true` when the value is `true` or `"true"` and `false` otherwise; `number` inputs become numbers; `string`, `choice` and `environment` inputs stay as given. Values for inputs the workflow doesn't declare are dropped. An `environment` input is a plain text field in the **Run workflow** form, and its value isn't checked against the repository's [environments](/docs/actions/environments). | |
| 323 | + | |
| 324 | +Read inputs with the `inputs` context (`${{ inputs.version }}`). `github.event.inputs` holds the same values with the same types; on GitHub, `github.event.inputs` values are always strings. | |
| 325 | + | |
| 326 | +The workflow must exist and have a `workflow_dispatch` trigger at the chosen ref. A ref given without `refs/` is a branch if a branch with that name exists, and a tag otherwise. | |
| 327 | + | |
| 328 | +| Variable | Value | | |
| 329 | +| --- | --- | | |
| 330 | +| `GITHUB_SHA` | The latest commit of the chosen ref | | |
| 331 | +| `GITHUB_REF` | `refs/heads/<branch>` or `refs/tags/<tag>` | | |
| 332 | + | |
| 333 | +Payload (`github.event`): `inputs`, `ref` (the full ref), `workflow` (the workflow file's path), `repository` and `sender`. | |
| 334 | + | |
| 335 | +Each accepted dispatch also sends a `workflow_dispatch` [webhook](/docs/reference/webhook-events#workflow_dispatch) event with the same `ref`, `workflow` and `inputs`, to webhooks subscribed to it. | |
| 336 | + | |
| 337 | +## schedule | |
| 338 | + | |
| 339 | +Runs the workflow on a schedule, in UTC. | |
| 340 | + | |
| 341 | +```yaml | |
| 342 | +on: | |
| 343 | + schedule: | |
| 344 | + - cron: "30 5 * * 1-5" # 05:30 UTC, Monday to Friday | |
| 345 | + - cron: "0 */6 * * *" # every 6 hours | |
| 346 | +``` | |
| 347 | + | |
| 348 | +`schedule` is a list of entries with a `cron` string. Each cron has 5 fields: | |
| 349 | + | |
| 350 | +| Field | Values | | |
| 351 | +| --- | --- | | |
| 352 | +| Minute | `0`-`59` | | |
| 353 | +| Hour | `0`-`23` | | |
| 354 | +| Day of month | `1`-`31` | | |
| 355 | +| Month | `1`-`12` or `JAN`-`DEC` | | |
| 356 | +| Day of week | `0`-`6` (Sunday is `0`; `7` also means Sunday) or `SUN`-`SAT` | | |
| 357 | + | |
| 358 | +Each field accepts `*`, a value, a range (`1-5`), a list (`1,15,30`) and a step (`*/15`, `0-30/10`, or `5/15` meaning from 5 to the end every 15). Names are case-insensitive. As in standard cron, when both day of month and day of week are restricted, a day matches if either one matches. | |
| 359 | + | |
| 360 | +How schedules run: | |
| 361 | + | |
| 362 | +- **Granularity is 5 minutes.** Diffinery checks schedules every 5 minutes and starts every schedule that was due at any minute since the last check. A schedule for `2 * * * *` starts a few minutes after :02, and one for `* * * * *` runs once per check, not every minute. GitHub's minimum interval is also 5 minutes. A check that comes late is caught up by the next one (up to an hour back), and a schedule starts once however many of its minutes it covers. | |
| 363 | +- **Only the default branch counts.** Schedules are read from the workflow files on the default branch, and refreshed on every push to it. A `schedule` in a workflow on another branch does nothing. | |
| 364 | +- Each run uses the workflow file on the default branch at that moment, and only if it still has that exact cron string. | |
| 365 | +- Schedules of archived repositories don't run. | |
| 366 | +- The run's actor is `diffinery-scheduler`. Notifications for scheduled runs go to the repository's watchers. | |
| 367 | + | |
| 368 | +Invalid crons fail the file with `Invalid cron: <detail>`, for example `Invalid cron: Cron '0 3 * *' must have exactly 5 fields (minute hour day-of-month month day-of-week)`, `minute value 60 out of range 0-59 in cron '<cron>'` or `Invalid step '0' in minute field of cron '<cron>'`. `schedule` must be a list (`on.schedule must be a list of { cron: '...' } entries`) and each entry needs a `cron` string (`on.schedule entries must have a 'cron' string`). | |
| 369 | + | |
| 370 | +| Variable | Value | | |
| 371 | +| --- | --- | | |
| 372 | +| `GITHUB_SHA` | The latest commit on the default branch | | |
| 373 | +| `GITHUB_REF` | `refs/heads/<default branch>` | | |
| 374 | + | |
| 375 | +Payload (`github.event`): `schedule` (the cron string) and `repository`. | |
| 376 | + | |
| 377 | +## workflow_run | |
| 378 | + | |
| 379 | +Runs after a run of another workflow is requested or completes. Use it to post-process another workflow's results with access to secrets, or to chain workflows. | |
| 380 | + | |
| 381 | +```yaml | |
| 382 | +name: Report | |
| 383 | +on: | |
| 384 | + workflow_run: | |
| 385 | + workflows: [CI] | |
| 386 | + types: [completed] | |
| 387 | + branches: [main] | |
| 388 | +jobs: | |
| 389 | + report: | |
| 390 | + if: ${{ github.event.workflow_run.conclusion == 'failure' }} | |
| 391 | + runs-on: ubuntu-latest | |
| 392 | + steps: | |
| 393 | + - run: echo "CI failed on ${{ github.event.workflow_run.head_branch }}: ${{ github.event.workflow_run.html_url }}" | |
| 394 | +``` | |
| 395 | + | |
| 396 | +- `workflows` is required: a list of workflow names (each workflow's `name`, or its path when it has none). A `workflow_run` trigger without `workflows` never fires. | |
| 397 | +- `types` can be `requested` (when the other run is created, including re-runs) and `completed`. Without `types`, both fire. GitHub's `in_progress` type never fires on Diffinery. | |
| 398 | +- `branches` and `branches-ignore` filter on the other run's head branch: the pushed branch, or the pull request's head branch for pull request runs. | |
| 399 | +- Like on GitHub, the triggered workflow is read from the default branch and runs there, whatever branch the other run was on. | |
| 400 | +- Chains stop after three levels: if A triggers B, B triggers C and C triggers D, D's runs don't trigger anything. | |
| 401 | +- If the run it follows came from an agent (for example CI on a branch an agent pushed), the `workflow_run` run counts as agent code too, although it runs on the default branch: its `GITHUB_TOKEN` acts as an agent and environment secrets follow the agent rules. A follower acts on what the other run produced (artifacts, outputs), so it can't be used to carry agent-written results past the agent guardrails. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent). | |
| 402 | + | |
| 403 | +| Variable | Value | | |
| 404 | +| --- | --- | | |
| 405 | +| `GITHUB_SHA` | The latest commit on the default branch | | |
| 406 | +| `GITHUB_REF` | `refs/heads/<default branch>` | | |
| 407 | + | |
| 408 | +Payload (`github.event`): | |
| 409 | + | |
| 410 | +| Field | Value | | |
| 411 | +| --- | --- | | |
| 412 | +| `action` | `requested` or `completed` | | |
| 413 | +| `workflow_run.id` | The other run's number, which is also its `github.run_id` and what `download-artifact`'s `run-id` takes | | |
| 414 | +| `workflow_run.name`, `workflow_run.path` | The other workflow's name and file path | | |
| 415 | +| `workflow_run.display_title` | The other run's title | | |
| 416 | +| `workflow_run.run_number`, `workflow_run.run_attempt` | Its number and attempt | | |
| 417 | +| `workflow_run.event` | The event that started it | | |
| 418 | +| `workflow_run.status`, `workflow_run.conclusion` | `queued` and `null` for `requested`; `completed` and the conclusion for `completed` | | |
| 419 | +| `workflow_run.head_branch`, `workflow_run.head_sha` | The branch and the commit it reported checks on | | |
| 420 | +| `workflow_run.actor`, `workflow_run.triggering_actor` | `{ "login": ... }` | | |
| 421 | +| `workflow_run.pull_requests` | `[{ "number": ... }]` for pull request runs, otherwise empty | | |
| 422 | +| `workflow_run.created_at`, `workflow_run.updated_at` | Timestamps | | |
| 423 | +| `workflow_run.html_url` | Link to the other run | | |
| 424 | +| `workflow` | `name` and `path` of the other workflow | | |
| 425 | +| `repository`, `sender` | As for other events | | |
| 426 | + | |
| 427 | +## release | |
| 428 | + | |
| 429 | +Runs when a release changes: when it's published, unpublished, edited or deleted. Use it to build and attach binaries, publish packages or deploy when you publish a release. Releases are managed on the repository's **Releases** page (`https://diffinery.com/acme/website/releases`), with `POST /api/v3/repos/{owner}/{repo}/releases` and `PATCH /api/v3/repos/{owner}/{repo}/releases/{release_id}`, or with the `create_release` and `update_release` MCP tools. All of them need write access. See [Releases](/docs/collaboration/releases). | |
| 430 | + | |
| 431 | +```yaml | |
| 432 | +name: Release | |
| 433 | +on: | |
| 434 | + release: | |
| 435 | + types: [published] | |
| 436 | +jobs: | |
| 437 | + build: | |
| 438 | + runs-on: ubuntu-latest | |
| 439 | + steps: | |
| 440 | + - uses: actions/checkout@v4 | |
| 441 | + - run: make dist VERSION="${{ github.event.release.tag_name }}" | |
| 442 | +``` | |
| 443 | + | |
| 444 | +Each change sends one or more activity types, and a workflow with `on: release` and no `types` runs once for each of them. Creating a published release sends three (`created`, `published` and `released`), so you get three runs. Set `types` to the ones you need; `types: [published]` is the usual choice. | |
| 445 | + | |
| 446 | +| Change | Activity types sent to workflows | | |
| 447 | +| --- | --- | | |
| 448 | +| Create a release that isn't a draft (**Publish release** on a new release) | `created`, `published`, then `released` (or `prereleased` for a pre-release) | | |
| 449 | +| Publish a draft (**Publish release** on a draft) | `published`, then `released` or `prereleased` | | |
| 450 | +| Turn a published release back into a draft (`"draft": true` in the API) | `unpublished` | | |
| 451 | +| Edit a published release (**Update release**) | `edited`, plus `released` or `prereleased` when the pre-release setting changed | | |
| 452 | +| Delete a published release | `deleted` | | |
| 453 | +| Create, edit or delete a draft | None. Webhooks still get `created`, `edited` and `deleted` for drafts, but workflows don't run for draft releases, as on GitHub. | | |
| 454 | + | |
| 455 | +How a release run is set up: | |
| 456 | + | |
| 457 | +- The run uses the commit the release's tag points to (the commit, for an annotated tag), and the workflow file at that commit. If the tag doesn't exist (for example it was deleted after the release was published), no run starts. | |
| 458 | +- Publishing a release whose tag doesn't exist yet creates the tag at `target_commitish` (the default branch when it's not set). Creating the tag is a push of that tag, so `push` workflows that match the tag run too, as on GitHub. Use either `on: push: tags` or `on: release` for the same job, not both. | |
| 459 | +- The run's title is the release's name, or its tag name when the release has no name. | |
| 460 | +- The run's actor is whoever made the change. Its checks are reported on the tagged commit. | |
| 461 | +- `types` is the only filter. `branches`, `tags` and `paths` aren't applied to `release`; check `github.event.release.tag_name` or `github.ref_name` in an `if:` instead. | |
| 462 | +- Invalid workflow files are skipped without an `Invalid workflow file` run. | |
| 463 | + | |
| 464 | +| Variable | Value | | |
| 465 | +| --- | --- | | |
| 466 | +| `GITHUB_SHA` | The commit the release's tag points to | | |
| 467 | +| `GITHUB_REF` | `refs/tags/<tag>` | | |
| 468 | +| `GITHUB_REF_NAME` | The tag name | | |
| 469 | +| `GITHUB_REF_TYPE` | `tag` | | |
| 470 | + | |
| 471 | +Payload (`github.event`): | |
| 472 | + | |
| 473 | +| Field | Value | | |
| 474 | +| --- | --- | | |
| 475 | +| `action` | The activity type | | |
| 476 | +| `release.id` | The release's ID, a number | | |
| 477 | +| `release.tag_name` | The tag, for example `v1.2.0` | | |
| 478 | +| `release.target_commitish` | The branch or commit the tag was created from | | |
| 479 | +| `release.name`, `release.body` | Title and release notes, or `null` when empty | | |
| 480 | +| `release.draft`, `release.prerelease` | Booleans | | |
| 481 | +| `release.latest` | Whether this is the repository's latest release (a Diffinery field) | | |
| 482 | +| `release.immutable` | Always `false` | | |
| 483 | +| `release.created_at`, `release.updated_at`, `release.published_at` | Timestamps (`published_at` is `null` for drafts) | | |
| 484 | +| `release.author` | The user object of whoever created the release (`login`, `id`, `type` and URLs) | | |
| 485 | +| `release.assets` | The release's assets, each with `id`, `name`, `label`, `content_type`, `size`, `download_count`, `browser_download_url` and `url` | | |
| 486 | +| `release.html_url` | `https://diffinery.com/acme/website/releases/tag/<tag>` | | |
| 487 | +| `release.url`, `release.assets_url`, `release.upload_url` | API URLs. Upload assets to `upload_url` (with the `{?name,label}` template removed). | | |
| 488 | +| `release.tarball_url`, `release.zipball_url` | Source archives of the tag | | |
| 489 | +| `release.node_id`, `release.reactions` | As on GitHub | | |
| 490 | +| `repository` | See [The repository object](#the-repository-object) | | |
| 491 | +| `sender` | See [The sender object](#the-sender-object) | | |
| 492 | + | |
| 493 | +Uploading an asset needs `contents: write`. Give the job that permission and use its `GITHUB_TOKEN`, or use a personal or agent token stored as a secret (see [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions)): | |
| 494 | + | |
| 495 | +```yaml | |
| 496 | +on: | |
| 497 | + release: | |
| 498 | + types: [published] | |
| 499 | +jobs: | |
| 500 | + upload: | |
| 501 | + runs-on: ubuntu-latest | |
| 502 | + steps: | |
| 503 | + - uses: actions/checkout@v4 | |
| 504 | + - run: tar czf dist.tar.gz src | |
| 505 | + - name: Attach dist.tar.gz to the release | |
| 506 | + env: | |
| 507 | + TOKEN: ${{ secrets.RELEASE_TOKEN }} | |
| 508 | + UPLOAD_URL: ${{ github.event.release.upload_url }} | |
| 509 | + run: | | |
| 510 | + curl -fsS -X POST "${UPLOAD_URL%\{*}?name=dist.tar.gz" \ | |
| 511 | + -H "Authorization: Bearer $TOKEN" \ | |
| 512 | + -H "Content-Type: application/gzip" \ | |
| 513 | + --data-binary @dist.tar.gz | |
| 514 | +``` | |
| 515 | + | |
| 516 | +## repository_dispatch | |
| 517 | + | |
| 518 | +Runs when someone calls the `dispatches` endpoint of the repository with an event type. Use it to start workflows from outside Diffinery: another system's CI, a deployment tool, a chat bot or an agent. | |
| 519 | + | |
| 520 | +```yaml | |
| 521 | +on: | |
| 522 | + repository_dispatch: | |
| 523 | + types: [deploy, rollback] | |
| 524 | +jobs: | |
| 525 | + deploy: | |
| 526 | + runs-on: ubuntu-latest | |
| 527 | + steps: | |
| 528 | + - uses: actions/checkout@v4 | |
| 529 | + - run: ./deploy.sh "${{ github.event.action }}" "${{ github.event.client_payload.version }}" | |
| 530 | +``` | |
| 531 | + | |
| 532 | +- `types` lists the `event_type` values that start the workflow. Without `types`, every `event_type` does. | |
| 533 | +- Like on GitHub, the workflow is read from the default branch's latest commit and runs there. | |
| 534 | +- The run's title is the `event_type`, and its actor is the account whose token sent the request. | |
| 535 | +- Invalid workflow files are skipped without an `Invalid workflow file` run. | |
| 536 | + | |
| 537 | +### Send a repository_dispatch event | |
| 538 | + | |
| 539 | +You need write access to the repository, with a token that has the write scope (see [Tokens](/docs/accounts/tokens)). People and agents can both send events. | |
| 540 | + | |
| 541 | +```sh | |
| 542 | +curl -X POST https://diffinery.com/api/v3/repos/acme/website/dispatches \ | |
| 543 | + -H "Authorization: Bearer dfy_..." \ | |
| 544 | + -H "Content-Type: application/json" \ | |
| 545 | + -d '{"event_type": "deploy", "client_payload": {"version": "1.4.2", "environment": "staging"}}' | |
| 546 | +``` | |
| 547 | + | |
| 548 | +| Field | Required | Meaning | | |
| 549 | +| --- | --- | --- | | |
| 550 | +| `event_type` | Yes | A name for the event, 1 to 100 characters. Workflows match it against `types`, and it becomes `github.event.action`. | | |
| 551 | +| `client_payload` | No | A JSON object with at most 10 top-level properties and at most 64 KB as JSON. Becomes `github.event.client_payload`. Default: `{}`. | | |
| 552 | + | |
| 553 | +The answer is `204 No Content`, as on GitHub, also when no workflow listens for the event type. The request doesn't return the runs it started; find them with `GET /api/v3/repos/acme/website/actions/runs?event=repository_dispatch` or `diffinery run list -R acme/website -e repository_dispatch`. Webhooks subscribed to `repository_dispatch` also get the event. | |
| 554 | + | |
| 555 | +| Status | Message | Cause | | |
| 556 | +| --- | --- | --- | | |
| 557 | +| 403 | `write access required` | Your role or token scope is below write. | | |
| 558 | +| 403 | `repository is archived (read-only)` | Archived repositories don't accept events. | | |
| 559 | +| 422 | `event_type is required (at most 100 characters)` | `event_type` is missing, empty, not a string or too long. | | |
| 560 | +| 422 | `client_payload must be an object` | `client_payload` is an array or a scalar. | | |
| 561 | +| 422 | `client_payload can have at most 10 top-level properties` | Nest the values in fewer top-level properties. | | |
| 562 | +| 422 | `client_payload is too large` | The payload is over 64 KB as JSON. | | |
| 563 | +| 422 | `the repository is empty` | The default branch has no commits yet. | | |
| 564 | + | |
| 565 | +| Variable | Value | | |
| 566 | +| --- | --- | | |
| 567 | +| `GITHUB_SHA` | The latest commit on the default branch | | |
| 568 | +| `GITHUB_REF` | `refs/heads/<default branch>` | | |
| 569 | + | |
| 570 | +Payload (`github.event`): | |
| 571 | + | |
| 572 | +| Field | Value | | |
| 573 | +| --- | --- | | |
| 574 | +| `action` | The `event_type` | | |
| 575 | +| `branch` | The default branch | | |
| 576 | +| `client_payload` | The `client_payload` you sent | | |
| 577 | +| `repository` | See [The repository object](#the-repository-object) | | |
| 578 | +| `sender` | The account whose token sent the event; see [The sender object](#the-sender-object) | | |
| 579 | + | |
| 580 | +## workflow_call | |
| 581 | + | |
| 582 | +Makes the workflow reusable: other workflows call it from a job with `uses:`. It never starts a run on its own. | |
| 583 | + | |
| 584 | +```yaml | |
| 585 | +on: | |
| 586 | + workflow_call: | |
| 587 | + inputs: | |
| 588 | + node-version: | |
| 589 | + type: string | |
| 590 | + default: "22" | |
| 591 | + secrets: | |
| 592 | + npm-token: | |
| 593 | + required: true | |
| 594 | + outputs: | |
| 595 | + version: | |
| 596 | + value: ${{ jobs.build.outputs.version }} | |
| 597 | +``` | |
| 598 | + | |
| 599 | +`inputs` take `type` (required: `string`, `boolean` or `number`), `description`, `required` and `default`. `secrets` take `description` and `required`. `outputs` each need a `value`. Runs of a called workflow use the caller's event, ref, commit and `github` context. The full reference is in [Reusable workflows](/docs/actions/reusable-workflows). | |
| 600 | + | |
| 601 | +## GITHUB_SHA and GITHUB_REF by event | |
| 602 | + | |
| 603 | +| Event | `GITHUB_SHA` | `GITHUB_REF` | Workflow file read from | | |
| 604 | +| --- | --- | --- | --- | | |
| 605 | +| `push` | Pushed commit | Pushed branch or tag | Pushed commit | | |
| 606 | +| `pull_request` | Test merge commit (merge commit once merged) | `refs/pull/<n>/merge` | Test merge commit | | |
| 607 | +| `pull_request_target` | Base branch's latest commit | `refs/heads/<base>` | Base branch's latest commit | | |
| 608 | +| `merge_group` | Merge group commit | `refs/heads/gh-readonly-queue/<base>/pr-<n>-<head sha>` | Merge group commit | | |
| 609 | +| `workflow_dispatch` | Latest commit of the chosen ref | Chosen branch or tag | Chosen ref | | |
| 610 | +| `schedule` | Default branch's latest commit | Default branch | Default branch | | |
| 611 | +| `workflow_run` | Default branch's latest commit | Default branch | Default branch | | |
| 612 | +| `release` | The commit the release's tag points to | `refs/tags/<tag>` | The tagged commit | | |
| 613 | +| `repository_dispatch` | Default branch's latest commit | Default branch | Default branch | | |
| 614 | + | |
| 615 | +A re-run keeps the original run's commit, ref, event and payload. | |
| 616 | + | |
| 617 | +## The repository object | |
| 618 | + | |
| 619 | +Every payload has a `repository` field with GitHub's full repository object, the same one the REST API returns for `GET /repos/{owner}/{repo}`. The fields workflows read most: | |
| 620 | + | |
| 621 | +| Field | Value | | |
| 622 | +| --- | --- | | |
| 623 | +| `id` | The repository's ID, a number | | |
| 624 | +| `node_id` | An opaque ID | | |
| 625 | +| `name` | `website` | | |
| 626 | +| `full_name` | `acme/website` | | |
| 627 | +| `private` | `true` for a private repository, `false` for a public one | | |
| 628 | +| `visibility` | `public` or `private` | | |
| 629 | +| `owner` | A user object for the owner (`login`, a numeric `id`, `type`: `User` or `Organization`, and URLs) | | |
| 630 | +| `default_branch` | `main` | | |
| 631 | +| `archived` | Whether the repository is archived | | |
| 632 | +| `topics` | The repository's topics | | |
| 633 | +| `html_url` | `https://diffinery.com/acme/website` | | |
| 634 | +| `clone_url` | `https://diffinery.com/acme/website.git` | | |
| 635 | +| `url` and the `*_url` templates | API URLs under `https://diffinery.com/api/v3/repos/acme/website` | | |
| 636 | +| `created_at`, `updated_at`, `pushed_at` | Timestamps | | |
| 637 | + | |
| 638 | +For a repository owned by an organization, payloads also have an `organization` field with `login`, `id` (a number), `node_id`, `url`, `repos_url`, `avatar_url` and `description`. | |
| 639 | + | |
| 640 | +The payload is also written to the file at `GITHUB_EVENT_PATH` for tools that read it from disk, such as `@actions/github`'s `context.payload` in `actions/github-script`. | |
| 641 | + | |
| 642 | +## The sender object | |
| 643 | + | |
| 644 | +`sender` is the account that caused the event, as a GitHub user object: | |
| 645 | + | |
| 646 | +| Field | Value | | |
| 647 | +| --- | --- | | |
| 648 | +| `login` | The account's login | | |
| 649 | +| `id` | A number derived from the account's ID, like GitHub's numeric user IDs | | |
| 650 | +| `node_id` | An opaque ID | | |
| 651 | +| `type` | `User` for people, `Bot` for agents and Diffinery itself (for example `diffinery-scheduler`) | | |
| 652 | +| `site_admin` | `false` | | |
| 653 | +| `avatar_url`, `html_url`, `url` | The avatar, the profile page and the API URL | | |
| 654 | + | |
| 655 | +## Runs started from inside a workflow | |
| 656 | + | |
| 657 | +As on GitHub, an event caused by a job's own `GITHUB_TOKEN` doesn't start new workflow runs, so workflows can't trigger themselves in a loop. A push, a pull request or a release made with a job token starts nothing. The two exceptions, as on GitHub, are `workflow_dispatch` (a job with `permissions: actions: write` can start another workflow with `POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`) and `repository_dispatch` (a job with `contents: write` can send `POST /repos/{owner}/{repo}/dispatches`). | |
| 658 | + | |
| 659 | +Events caused by a personal or agent token stored as a secret are treated like any other event from that account and do start workflows: a push starts `push` workflows, a published release starts `release` workflows, and a dispatch starts `repository_dispatch` workflows. | |
| 660 | + | |
| 661 | +```yaml | |
| 662 | +- name: Ask the deploy workflow to run | |
| 663 | + env: | |
| 664 | + TOKEN: ${{ secrets.DISPATCH_TOKEN }} | |
| 665 | + run: | | |
| 666 | + curl -fsS -X POST "${{ github.api_url }}/repos/${{ github.repository }}/dispatches" \ | |
| 667 | + -H "Authorization: Bearer $TOKEN" \ | |
| 668 | + -H "Content-Type: application/json" \ | |
| 669 | + -d '{"event_type": "deploy", "client_payload": {"sha": "${{ github.sha }}"}}' | |
| 670 | +``` | |
| 671 | + | |
| 672 | +Guard against loops, for example with `paths-ignore`, `types`, or an `if:` that checks `github.actor` or the commit message. | |
| 673 | + | |
| 674 | +## Differences from GitHub | |
| 675 | + | |
| 676 | +- Only the events in [Supported events](#supported-events) start runs. Other GitHub event names parse but never fire. | |
| 677 | +- `pull_request` and `pull_request_target` fire for `opened`, `synchronize`, `reopened`, `closed`, `edited`, `ready_for_review` and `converted_to_draft` only, and the `edited` payload has no `changes` object. | |
| 678 | +- `workflow_run` fires for `requested` and `completed`, not `in_progress`, and requires `workflows`. | |
| 679 | +- `release` workflows get no `branches`, `tags` or `paths` filtering, and a published release whose tag doesn't exist any more starts no runs. | |
| 680 | +- `deployment` and `deployment_status` events go to webhooks (a job with `environment:` sends both), but don't start workflow runs. | |
| 681 | +- `push` payloads list only the pushed commit in `commits`. | |
| 682 | +- Path filters pass when a push creates a branch. | |
| 683 | +- `github.event.inputs` keeps input types. | |
| 684 | +- `schedule` is checked every 5 minutes; a schedule runs within about 5 minutes after it is due. |
addedapps/diffinery/docs/actions/expressions.md+424−0View file
| @@ -1,0 +1,424 @@ | ||
| 1 | +--- | |
| 2 | +title: Expressions and contexts | |
| 3 | +description: The ${{ }} expression language in Diffinery workflows: operators, functions, every context property Diffinery sets, and the default environment variables. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +Expressions let a workflow compute values and conditions at run time: `${{ github.ref_name }}`, `if: failure()`, `${{ fromJSON(needs.plan.outputs.matrix) }}`. Diffinery implements the GitHub Actions expression language, with the same syntax, operators, functions and type rules, and fills in the same contexts. This page is the reference for all of it, including the default environment variables every step gets and every difference from GitHub. | |
| 7 | + | |
| 8 | +## Syntax | |
| 9 | + | |
| 10 | +An expression is written between `${{` and `}}`: | |
| 11 | + | |
| 12 | +```yaml | |
| 13 | +env: | |
| 14 | + IMAGE: diffinery.com/acme/website:${{ github.sha }} | |
| 15 | +steps: | |
| 16 | + - run: echo "Building ${{ github.ref_name }} for ${{ github.actor }}" | |
| 17 | +``` | |
| 18 | + | |
| 19 | +- In most values, each `${{ }}` is replaced by its result converted to a string, and the text around it is kept. | |
| 20 | +- When a value is exactly one expression (for example `matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}`), the result keeps its type: a list stays a list, a number stays a number. | |
| 21 | +- In `if:` conditions of jobs and steps, `${{ }}` is optional: `if: github.event_name == 'push'` is the same as `if: ${{ github.event_name == 'push' }}`. | |
| 22 | +- `}}` inside a single-quoted string doesn't end the expression. To write a literal `${{`, use `${{ '${{' }}`. | |
| 23 | +- Expressions in `on:` are not evaluated. | |
| 24 | + | |
| 25 | +Every expression in a file is checked for syntax when the file is parsed. A mistake fails the file with `Invalid expression ${{ <expr> }}: <detail>` (or `Invalid expression: <detail>` in an `if:` without `${{ }}`), and an unclosed one with `Unclosed expression: '${{' at offset <n> has no matching '}}'`. Run `diffinery workflow validate` to catch these before you push. | |
| 26 | + | |
| 27 | +> **Note:** In an `if:` that mixes text and expressions, such as `if: ${{ a }} && ${{ b }}`, the result is a string, which is true unless it's empty. Write the whole condition as one expression instead: `if: a && b`. GitHub behaves the same way. | |
| 28 | + | |
| 29 | +## Literals | |
| 30 | + | |
| 31 | +| Type | Examples | Notes | | |
| 32 | +| --- | --- | --- | | |
| 33 | +| null | `null` | | | |
| 34 | +| Boolean | `true`, `false` | | | |
| 35 | +| Number | `42`, `-1`, `3.14`, `2e3`, `0xff`, `0o17`, `NaN`, `Infinity` | Any JSON number, plus hexadecimal (`0x`) and octal (`0o`). A leading `-` or `+` is part of the number: there is no arithmetic and no unary minus. | | |
| 36 | +| String | `'hello'`, `'it''s'` | Single quotes only; write `''` for a quote. Double quotes fail with `Unexpected double quote; string literals use single quotes`. | | |
| 37 | + | |
| 38 | +## Operators | |
| 39 | + | |
| 40 | +| Operator | Meaning | | |
| 41 | +| --- | --- | | |
| 42 | +| `( )` | Grouping | | |
| 43 | +| `[ ]` | Index: `github.event['pull_request']`, `matrix.include[0]` | | |
| 44 | +| `.` | Property: `github.event.pull_request.title` | | |
| 45 | +| `.*`, `[*]` | Object filter (see below) | | |
| 46 | +| `!` | Not | | |
| 47 | +| `<`, `<=`, `>`, `>=` | Comparison | | |
| 48 | +| `==`, `!=` | Equality | | |
| 49 | +| `&&` | And | | |
| 50 | + | |
| 51 | +The or operator is `||`. Precedence, from highest to lowest: property access and indexing, `!`, comparisons, equality, `&&`, `||`. There are no arithmetic operators, as on GitHub. | |
| 52 | + | |
| 53 | +`&&` and `||` return one of their operands, not a boolean: `a && b` is `a` when `a` is falsy and `b` otherwise; `a || b` is `a` when `a` is truthy and `b` otherwise. That gives you defaults: `${{ inputs.tag || 'latest' }}`. | |
| 54 | + | |
| 55 | +### Property access | |
| 56 | + | |
| 57 | +- Property names are case-insensitive: `github.EVENT_NAME` reads `github.event_name`. | |
| 58 | +- Names may contain `-`: `steps.build-app.outputs.path`, `matrix.node-version`. | |
| 59 | +- Reading a property of something that doesn't have it gives `null` instead of an error, so `github.event.pull_request.title` is `null` on a push. An unknown context name is `null` too. | |
| 60 | +- Array indexes start at 0. An index out of range gives `null`. | |
| 61 | + | |
| 62 | +### Object filters | |
| 63 | + | |
| 64 | +`.*` collects a property from every item of an array or object and gives a list: | |
| 65 | + | |
| 66 | +```yaml | |
| 67 | +- run: echo "${{ toJSON(github.event.pull_request.labels.*.name) }}" | |
| 68 | +- if: contains(github.event.pull_request.labels.*.name, 'deploy') | |
| 69 | + run: ./deploy.sh | |
| 70 | +``` | |
| 71 | + | |
| 72 | +### Types and conversion | |
| 73 | + | |
| 74 | +Comparisons convert values the way GitHub does: | |
| 75 | + | |
| 76 | +| Rule | Example | | |
| 77 | +| --- | --- | | |
| 78 | +| Values of different types are compared as numbers. `null` is `0`, `true` is `1`, `false` is `0`, `''` is `0`, numeric strings are their number, and anything else is `NaN`. | `'1' == 1` is true, `null == 0` is true | | |
| 79 | +| Strings are compared case-insensitively. | `'Main' == 'main'` is true | | |
| 80 | +| A comparison with `NaN` is false. | `'abc' < 1` is false | | |
| 81 | +| Arrays and objects are only equal to themselves. | `fromJSON('[1]') == fromJSON('[1]')` is false | | |
| 82 | + | |
| 83 | +Truthiness: `false`, `0`, `-0`, `NaN`, `''` and `null` are falsy; everything else, including every array and object, is truthy. | |
| 84 | + | |
| 85 | +When a result is inserted into text: `null` becomes `''`, booleans become `true` or `false`, whole numbers have no decimals, and arrays and objects become JSON (indented by 2 spaces). | |
| 86 | + | |
| 87 | +## Functions | |
| 88 | + | |
| 89 | +Function names are case-insensitive. Calling an unknown function fails with `Unrecognized function '<name>'`, and a wrong number of arguments with `Function '<name>' expects <n> argument(s) but got <m>`. | |
| 90 | + | |
| 91 | +| Function | Returns | | |
| 92 | +| --- | --- | | |
| 93 | +| `contains(search, item)` | If `search` is an array: `true` when an element equals `item` (with the equality rules above). Otherwise `true` when the string `search` contains `item`, ignoring case. | | |
| 94 | +| `startsWith(string, prefix)` | `true` when `string` starts with `prefix`, ignoring case. | | |
| 95 | +| `endsWith(string, suffix)` | `true` when `string` ends with `suffix`, ignoring case. | | |
| 96 | +| `format(string, value0, value1, ...)` | `string` with `{0}`, `{1}`, ... replaced by the values. Write `{{` and `}}` for literal braces. | | |
| 97 | +| `join(array, separator)` | The elements joined with `separator` (default `,`). A non-array is converted to a string. | | |
| 98 | +| `toJSON(value)` | `value` as JSON, indented by 2 spaces. | | |
| 99 | +| `fromJSON(string)` | The parsed value. Use it to turn strings into booleans, numbers, lists or objects. | | |
| 100 | +| `hashFiles(pattern, ...)` | A SHA-256 hash of the matching files, or `''` when none match (see below). | | |
| 101 | +| `success()`, `failure()`, `cancelled()`, `always()` | Status checks (see below). | | |
| 102 | + | |
| 103 | +Examples: | |
| 104 | + | |
| 105 | +```yaml | |
| 106 | +- run: echo "${{ format('{0}/{1}@{2}', github.repository_owner, github.event.repository.name, github.sha) }}" | |
| 107 | +- if: startsWith(github.ref, 'refs/tags/v') | |
| 108 | + run: ./release.sh | |
| 109 | +- uses: actions/cache@v4 | |
| 110 | + with: | |
| 111 | + path: ~/.npm | |
| 112 | + key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} | |
| 113 | +``` | |
| 114 | + | |
| 115 | +Runtime errors from functions fail the step or job that evaluates them: | |
| 116 | + | |
| 117 | +| Message | Cause | | |
| 118 | +| --- | --- | | |
| 119 | +| `fromJSON: invalid JSON "<text>": <detail>` | The argument isn't valid JSON. | | |
| 120 | +| `format: invalid format string '<string>'` | A `{` isn't followed by a number and `}`. | | |
| 121 | +| `format: invalid format string '<string>' (unescaped '}')` | A lone `}`. Write `}}`. | | |
| 122 | +| `format: placeholder {<n>} has no matching argument in '<string>'` | Not enough values. | | |
| 123 | + | |
| 124 | +### hashFiles | |
| 125 | + | |
| 126 | +`hashFiles` takes one or more glob patterns relative to `GITHUB_WORKSPACE` (`*`, `**`, `?`, `[...]`, and `!pattern` to exclude). It hashes each matching file with SHA-256, then hashes those hashes together, and returns the hex digest. Files outside the workspace and `.git` directories are ignored. It returns `''` when no file matches. | |
| 127 | + | |
| 128 | +`hashFiles` only works in places evaluated on the runner, that is in steps, job `env`, `container`, `services` and `outputs`. Elsewhere (job `if`, `runs-on`, `strategy`, `name`) it returns `''`. | |
| 129 | + | |
| 130 | +### Status check functions | |
| 131 | + | |
| 132 | +In a job's `if:`, the functions look at all of the job's ancestors: the jobs in its `needs`, the jobs those need, and so on up the chain, as on GitHub. | |
| 133 | + | |
| 134 | +| Function | True when | | |
| 135 | +| --- | --- | | |
| 136 | +| `success()` | The run hasn't been cancelled and every ancestor succeeded. A skipped or cancelled ancestor makes it false. | | |
| 137 | +| `failure()` | An ancestor failed or timed out. | | |
| 138 | +| `cancelled()` | The run has been cancelled. | | |
| 139 | +| `always()` | Always. | | |
| 140 | + | |
| 141 | +A matrix job's result combines its legs: any failed leg fails it, otherwise any cancelled leg cancels it, and it's skipped when every leg was skipped. A leg that failed with `continue-on-error: true` counts as a success. A reusable workflow call's result combines the jobs inside it the same way. | |
| 142 | + | |
| 143 | +In a step's `if:`: | |
| 144 | + | |
| 145 | +| Function | True when | | |
| 146 | +| --- | --- | | |
| 147 | +| `success()` | No earlier step of the job failed (steps with `continue-on-error: true` don't count) and the job isn't being cancelled or timing out. | | |
| 148 | +| `failure()` | An earlier step failed, and the job isn't being cancelled or timing out. | | |
| 149 | +| `cancelled()` | The job is being cancelled or has hit its timeout. | | |
| 150 | +| `always()` | Always. | | |
| 151 | + | |
| 152 | +When a job on a Diffinery-hosted runner is cancelled or hits its `timeout-minutes`, the step that is running stops, and then, as on GitHub, the remaining steps whose `if:` is true still run: `if: always()`, `if: cancelled()` and post steps such as the cache save with `save-always`. They have 5 minutes together. Use them to clean up what the job created, for example a test environment or a temporary repository. Cancelling the run a second time stops them at once. The job still ends as `cancelled` (or failed, after a timeout). | |
| 153 | + | |
| 154 | +An `if:` that doesn't call any of these is treated as `success() && (<condition>)`. So `if: github.ref == 'refs/heads/main'` is skipped after a failure, while `if: always() && github.ref == 'refs/heads/main'` still runs. Inside a composite action, the functions look at the action's own steps. | |
| 155 | + | |
| 156 | +```yaml | |
| 157 | +jobs: | |
| 158 | + test: | |
| 159 | + runs-on: ubuntu-latest | |
| 160 | + steps: | |
| 161 | + - run: npm test | |
| 162 | + - if: failure() | |
| 163 | + run: echo "::error::Tests failed" | |
| 164 | + notify: | |
| 165 | + needs: test | |
| 166 | + if: always() | |
| 167 | + runs-on: ubuntu-latest | |
| 168 | + steps: | |
| 169 | + - run: echo "test finished with ${{ needs.test.result }}" | |
| 170 | +``` | |
| 171 | + | |
| 172 | +Because a job's default `if:` is `success()`, a skipped job skips every job that depends on it, directly or further down the chain. To run a job anyway, give it a status function, for example `if: always()` or `if: ${{ !cancelled() && needs.build.result != 'failure' }}`: | |
| 173 | + | |
| 174 | +```yaml | |
| 175 | +jobs: | |
| 176 | + build: | |
| 177 | + if: github.event_name == 'push' | |
| 178 | + runs-on: ubuntu-latest | |
| 179 | + steps: | |
| 180 | + - run: make | |
| 181 | + test: | |
| 182 | + needs: build # skipped when build is skipped | |
| 183 | + runs-on: ubuntu-latest | |
| 184 | + steps: | |
| 185 | + - run: make test | |
| 186 | + report: | |
| 187 | + needs: test | |
| 188 | + if: ${{ !cancelled() }} # runs even when build and test were skipped | |
| 189 | + runs-on: ubuntu-latest | |
| 190 | + steps: | |
| 191 | + - run: echo "test result was ${{ needs.test.result }}" | |
| 192 | +``` | |
| 193 | + | |
| 194 | +## Contexts | |
| 195 | + | |
| 196 | +### Where each context is available | |
| 197 | + | |
| 198 | +Some values are evaluated by Diffinery before a job starts, and the rest by the runner while the job runs, so not every context is available everywhere: | |
| 199 | + | |
| 200 | +| Where | Available contexts | | |
| 201 | +| --- | --- | | |
| 202 | +| `run-name`, top-level `concurrency` | `github`, `inputs`, `vars` | | |
| 203 | +| `jobs.<id>.if` | `github`, `needs`, `inputs`, `vars`, `env` (workflow-level values, unevaluated), status functions | | |
| 204 | +| `jobs.<id>.strategy` | `github`, `needs`, `inputs`, `vars`, `env` | | |
| 205 | +| `jobs.<id>.name`, `jobs.<id>.runs-on`, `jobs.<id>.timeout-minutes`, `jobs.<id>.continue-on-error`, `jobs.<id>.concurrency` | `github`, `needs`, `inputs`, `vars`, `env`, `matrix`, `strategy`. These are evaluated for each matrix combination. | | |
| 206 | +| `jobs.<id>.environment` (`name` and `url`) | `github`, `needs`, `inputs`, `vars`, `matrix`, `strategy`. A `url` that refers to `steps.` is ignored. | | |
| 207 | +| `jobs.<id>.with` (reusable workflow call) | `github`, `needs`, `inputs`, `vars` | | |
| 208 | +| `jobs.<id>.secrets` (reusable workflow call) | `github`, `needs`, `inputs`, `vars`, `secrets` | | |
| 209 | +| `jobs.<id>.env`, `container`, `services` | `github`, `needs`, `inputs`, `vars`, `secrets`, `matrix`, `strategy`, `runner`, `job` | | |
| 210 | +| Steps: `if`, `name`, `uses`, `run`, `with`, `env`, `shell`, `working-directory`, `continue-on-error`, `timeout-minutes` | `github`, `env`, `vars`, `secrets`, `matrix`, `needs`, `steps`, `runner`, `inputs`, `job`, `strategy` | | |
| 211 | +| `jobs.<id>.outputs` | Same as steps | | |
| 212 | +| `on.workflow_call.outputs` | `github`, `jobs`, `inputs`, `vars` | | |
| 213 | + | |
| 214 | +In places evaluated before the job starts, `github.token` is empty and `github.workspace` isn't set, and `vars` and `secrets` hold only repository (and organization) values: a job's [environment](/docs/actions/workflow-syntax#environment) variables and secrets are added when the job starts, so they're available in steps, `jobs.<id>.env`, `container`, `services` and `outputs`, but not in `jobs.<id>.if`, `runs-on`, `name` or `environment` itself. | |
| 215 | + | |
| 216 | +### github | |
| 217 | + | |
| 218 | +Information about the run and the event. Most properties are also environment variables (see [Default environment variables](#default-environment-variables)). | |
| 219 | + | |
| 220 | +| Property | Value | | |
| 221 | +| --- | --- | | |
| 222 | +| `github.event_name` | The event that started the run, for example `push`, `pull_request`, `release` or `repository_dispatch` | | |
| 223 | +| `github.event` | The event payload. See [Events that trigger workflows](/docs/actions/events) for each event's fields, for example `github.event.release.tag_name` for `release` and `github.event.client_payload` for `repository_dispatch`. | | |
| 224 | +| `github.sha` | The commit the run checks out. See the table in [Events that trigger workflows](/docs/actions/events#github_sha-and-github_ref-by-event). | | |
| 225 | +| `github.ref` | The full ref, for example `refs/heads/main`, `refs/tags/v1.2.0` (also for `release` runs) or `refs/pull/7/merge` | | |
| 226 | +| `github.ref_name` | The short ref: `main`, `v1.2.0`, or `7/merge` for pull requests | | |
| 227 | +| `github.ref_type` | `tag` for tags, `branch` otherwise | | |
| 228 | +| `github.ref_protected` | Always `false` | | |
| 229 | +| `github.head_ref` | The pull request's head branch for `pull_request` and `pull_request_target`, otherwise `''` | | |
| 230 | +| `github.base_ref` | The pull request's base branch for `pull_request` and `pull_request_target`, otherwise `''` | | |
| 231 | +| `github.repository` | `acme/website` | | |
| 232 | +| `github.repository_id` | The repository's ID | | |
| 233 | +| `github.repository_owner` | `acme` | | |
| 234 | +| `github.repositoryUrl` | `https://diffinery.com/acme/website.git` | | |
| 235 | +| `github.actor` | The login of the account that triggered the run (for a re-run, who re-ran it): for `release`, whoever published, edited or deleted the release; for `repository_dispatch`, the account whose token sent the event. Scheduled runs use `diffinery-scheduler`; merge queue groups use the login of whoever queued the pull request. | | |
| 236 | +| `github.triggering_actor` | Same as `github.actor` | | |
| 237 | +| `github.run_id` | The run number, as a string, for example `128`. It stays the same on a re-run, and the REST API accepts it wherever a run ID goes, so actions that parse it as a number work. | | |
| 238 | +| `github.run_number` | The run's number, as a string | | |
| 239 | +| `github.run_attempt` | `1`, or higher for re-runs, as a string | | |
| 240 | +| `github.workflow` | The workflow's `name`, or its path when it has none | | |
| 241 | +| `github.workflow_ref` | `acme/website/.github/workflows/ci.yml@refs/heads/main` | | |
| 242 | +| `github.job` | The job's ID (inside a reusable workflow, its ID in that workflow) | | |
| 243 | +| `github.server_url` | `https://diffinery.com` | | |
| 244 | +| `github.api_url` | `https://diffinery.com/api/v3` | | |
| 245 | +| `github.graphql_url` | `https://diffinery.com/api/graphql`. The job's token can't use the [GraphQL API](/docs/reference/graphql-api) yet | | |
| 246 | +| `github.token` | The job's token (same as `secrets.GITHUB_TOKEN`) | | |
| 247 | +| `github.workspace` | The workspace directory, `GITHUB_WORKSPACE` | | |
| 248 | +| `github.retention_days` | `90` | | |
| 249 | +| `github.secret_source` | `Actions` | | |
| 250 | +| `github.action` | `''` | | |
| 251 | +| `github.action_path` | Inside a composite action: the action's directory. Otherwise `''`. | | |
| 252 | +| `github.action_repository`, `github.action_ref` | Inside a composite action: the action's repository and ref. | | |
| 253 | + | |
| 254 | +GitHub properties Diffinery doesn't set include `github.actor_id`, `github.repository_owner_id`, `github.workflow_sha`, `github.event_path` (use the `GITHUB_EVENT_PATH` variable), `github.env`, `github.path`, `github.output`, `github.state` and `github.step_summary` (use the environment variables of the same names). | |
| 255 | + | |
| 256 | +> **Note:** `github.run_number` counts runs of all workflows in the repository, not each workflow separately as on GitHub. Because `github.run_id` is the run number, the two are always equal. | |
| 257 | + | |
| 258 | +### env | |
| 259 | + | |
| 260 | +Environment variables set in the workflow: the workflow's `env`, the job's `env`, the step's `env` and anything earlier steps wrote to `$GITHUB_ENV`. It doesn't include the default variables such as `GITHUB_SHA`; read those with `github.*` or in the shell. | |
| 261 | + | |
| 262 | +```yaml | |
| 263 | +env: | |
| 264 | + STAGE: production | |
| 265 | +jobs: | |
| 266 | + deploy: | |
| 267 | + runs-on: ubuntu-latest | |
| 268 | + steps: | |
| 269 | + - if: env.STAGE == 'production' | |
| 270 | + run: echo "Deploying to $STAGE" | |
| 271 | +``` | |
| 272 | + | |
| 273 | +### vars | |
| 274 | + | |
| 275 | +The repository's variables, set under **Settings** > **Secrets and variables** or with `PUT /repos/{owner}/{repo}/actions/variables/{name}`. In a job with `environment:`, the environment's variables are added once the job starts and win over repository variables with the same name. Names are stored in upper case, and lookups ignore case. An unset variable is `''`. See [Secrets and variables](/docs/actions/secrets-and-variables) and [Environments](/docs/actions/environments). | |
| 276 | + | |
| 277 | +### secrets | |
| 278 | + | |
| 279 | +| Property | Value | | |
| 280 | +| --- | --- | | |
| 281 | +| `secrets.<NAME>` | A repository secret, or an organization secret shared with the repository (by its `visibility`; see [Organization secrets](/docs/actions/secrets-and-variables#organization-secrets)). A repository secret wins over an organization secret with the same name. In a job with `environment:`, the environment's secrets win over both (pull request runs, and some runs whose code came from an agent, don't get environment secrets; see [environment](/docs/actions/workflow-syntax#environment)). | | |
| 282 | +| `secrets.GITHUB_TOKEN` | The job's token. What it may do follows the job's `permissions:`; see [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions). | | |
| 283 | +| `secrets.DIFFINERY_TOKEN` | The same job token, under a Diffinery name. | | |
| 284 | + | |
| 285 | +Secret names are stored in upper case and lookups ignore case; an unset secret is `''`. Inside a reusable workflow, `secrets` holds only what the caller passed (or everything with `secrets: inherit`), plus the two tokens. Secret values are masked in logs. | |
| 286 | + | |
| 287 | +### matrix | |
| 288 | + | |
| 289 | +The values of the current matrix combination, for example `matrix.os` and `matrix.node`. Empty for jobs without a matrix. | |
| 290 | + | |
| 291 | +### strategy | |
| 292 | + | |
| 293 | +| Property | Value | | |
| 294 | +| --- | --- | | |
| 295 | +| `strategy.fail-fast` | Whether `fail-fast` is on | | |
| 296 | +| `strategy.job-index` | This job's position in the matrix, from 0 | | |
| 297 | +| `strategy.job-total` | How many jobs the matrix produced | | |
| 298 | +| `strategy.max-parallel` | The effective `max-parallel` | | |
| 299 | + | |
| 300 | +### needs | |
| 301 | + | |
| 302 | +For each job in the job's `needs`: | |
| 303 | + | |
| 304 | +| Property | Value | | |
| 305 | +| --- | --- | | |
| 306 | +| `needs.<job_id>.result` | `success`, `failure`, `cancelled` or `skipped`. For a matrix job: `failure` if any of its jobs failed or timed out, otherwise `cancelled` if any was cancelled, `skipped` if all were skipped, otherwise `success`. | | |
| 307 | +| `needs.<job_id>.outputs.<name>` | The job's outputs (strings). For a matrix job, the merged outputs of all its jobs. For a reusable workflow call, the called workflow's `on.workflow_call.outputs`. | | |
| 308 | + | |
| 309 | +### steps | |
| 310 | + | |
| 311 | +For each earlier step with an `id` in the same job (or the same composite action): | |
| 312 | + | |
| 313 | +| Property | Value | | |
| 314 | +| --- | --- | | |
| 315 | +| `steps.<id>.outputs.<name>` | Outputs the step set with `$GITHUB_OUTPUT` or `::set-output` | | |
| 316 | +| `steps.<id>.outcome` | `success`, `failure`, `cancelled` or `skipped`, before `continue-on-error` | | |
| 317 | +| `steps.<id>.conclusion` | The same, after `continue-on-error` (a failed step with `continue-on-error: true` is `success`) | | |
| 318 | + | |
| 319 | +### runner | |
| 320 | + | |
| 321 | +| Property | Value | | |
| 322 | +| --- | --- | | |
| 323 | +| `runner.os` | `Linux` | | |
| 324 | +| `runner.arch` | `X64` on hosted runners (`ARM64`, `ARM` or `X86` on self-hosted machines with those processors) | | |
| 325 | +| `runner.name` | `diffinery` | | |
| 326 | +| `runner.environment` | `diffinery-hosted` | | |
| 327 | +| `runner.temp` | A temporary directory for the job; `/workspace/_temp` on hosted runners, where every job starts in a fresh container | | |
| 328 | +| `runner.tool_cache` | Where setup actions install tools; `/opt/hostedtoolcache` on hosted runners | | |
| 329 | +| `runner.workspace` | The parent of `GITHUB_WORKSPACE`; `/workspace` on hosted runners | | |
| 330 | +| `runner.debug` | `1` when debug logging is on (secret or variable `ACTIONS_STEP_DEBUG` set to `true`), otherwise `''`. `ACTIONS_RUNNER_DEBUG` doesn't change it. | | |
| 331 | + | |
| 332 | +### job | |
| 333 | + | |
| 334 | +| Property | Value | | |
| 335 | +| --- | --- | | |
| 336 | +| `job.status` | `success`, `failure` or `cancelled`: the job's state so far | | |
| 337 | +| `job.container.id`, `job.container.network` | The job container's ID and network, when the job has a `container` | | |
| 338 | +| `job.services.<name>.id`, `.network`, `.ports` | Each service container's ID, network and port mappings (`ports['5432']` is the host port) | | |
| 339 | + | |
| 340 | +### inputs | |
| 341 | + | |
| 342 | +For `workflow_dispatch` runs: the dispatch inputs, typed (booleans are booleans, numbers are numbers). Inside a reusable workflow: the inputs the caller passed with `with`, plus declared defaults. For other events: empty. | |
| 343 | + | |
| 344 | +### jobs | |
| 345 | + | |
| 346 | +Only in `on.workflow_call.outputs` of a reusable workflow: `jobs.<job_id>.result` and `jobs.<job_id>.outputs.<name>` for the called workflow's jobs. | |
| 347 | + | |
| 348 | +## Default environment variables | |
| 349 | + | |
| 350 | +Every step gets these variables. Workflows can't override the `GITHUB_*` and `RUNNER_*` ones; setting them in `env` has no effect. Paths are those of hosted runners, where the repository `acme/website` is checked out at `/workspace/website`. | |
| 351 | + | |
| 352 | +| Variable | Value | | |
| 353 | +| --- | --- | | |
| 354 | +| `CI` | `true` | | |
| 355 | +| `GITHUB_ACTIONS` | `true` | | |
| 356 | +| `DIFFINERY` | `true`. Use it to detect Diffinery in scripts. | | |
| 357 | +| `GITHUB_REPOSITORY` | `acme/website` | | |
| 358 | +| `GITHUB_REPOSITORY_OWNER` | `acme` | | |
| 359 | +| `GITHUB_SHA` | The commit the run is about (see [Events that trigger workflows](/docs/actions/events#github_sha-and-github_ref-by-event)) | | |
| 360 | +| `GITHUB_REF` | The full ref | | |
| 361 | +| `GITHUB_REF_NAME` | The short ref (`main`, `v1.2.0`, `7/merge`) | | |
| 362 | +| `GITHUB_REF_TYPE` | `branch` or `tag` | | |
| 363 | +| `GITHUB_HEAD_REF` | The head branch for pull request events, otherwise empty | | |
| 364 | +| `GITHUB_BASE_REF` | The base branch for pull request events, otherwise empty | | |
| 365 | +| `GITHUB_RUN_ID` | The run number, as in `github.run_id` | | |
| 366 | +| `GITHUB_RUN_NUMBER` | The run's number | | |
| 367 | +| `GITHUB_RUN_ATTEMPT` | The attempt number | | |
| 368 | +| `GITHUB_JOB` | The job's ID | | |
| 369 | +| `GITHUB_ACTOR` | Who triggered the run | | |
| 370 | +| `GITHUB_TRIGGERING_ACTOR` | Same as `GITHUB_ACTOR` | | |
| 371 | +| `GITHUB_EVENT_NAME` | The event name | | |
| 372 | +| `GITHUB_EVENT_PATH` | A JSON file holding the event payload, `/workspace/_temp/_github_workflow/event.json` | | |
| 373 | +| `GITHUB_SERVER_URL` | `https://diffinery.com` | | |
| 374 | +| `GITHUB_API_URL` | `https://diffinery.com/api/v3` | | |
| 375 | +| `GITHUB_GRAPHQL_URL` | `https://diffinery.com/api/graphql`. The job's token can't use the [GraphQL API](/docs/reference/graphql-api) yet | | |
| 376 | +| `GITHUB_CUSTOM_API_URL`, `GITHUB_CUSTOM_SERVER_URL` | The same as `GITHUB_API_URL` and `GITHUB_SERVER_URL`. Tools built for GitHub Enterprise, such as super-linter, read these; without them they call api.github.com with the job's token | | |
| 377 | +| `GITHUB_WORKFLOW` | The workflow's name, or its path | | |
| 378 | +| `GITHUB_WORKFLOW_REF` | `acme/website/.github/workflows/ci.yml@refs/heads/main` | | |
| 379 | +| `GITHUB_WORKSPACE` | `/workspace/website` | | |
| 380 | +| `GITHUB_RETENTION_DAYS` | `90` | | |
| 381 | +| `GITHUB_ACTION` | The step's `id`, or `__run` for a `run` step without one (for actions, `__` plus the action's repository with other characters replaced by `_`) | | |
| 382 | +| `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_OUTPUT`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY` | Paths of the step's [environment files](/docs/actions/workflow-syntax#environment-files) | | |
| 383 | +| `RUNNER_OS` | `Linux` | | |
| 384 | +| `RUNNER_ARCH` | `X64` on hosted runners | | |
| 385 | +| `RUNNER_NAME` | `diffinery` | | |
| 386 | +| `RUNNER_ENVIRONMENT` | `diffinery-hosted` | | |
| 387 | +| `RUNNER_TEMP` | `/workspace/_temp` | | |
| 388 | +| `RUNNER_TOOL_CACHE` | `/opt/hostedtoolcache` | | |
| 389 | +| `RUNNER_WORKSPACE` | `/workspace` | | |
| 390 | +| `AGENT_TOOLSDIRECTORY` | Same as `RUNNER_TOOL_CACHE` | | |
| 391 | +| `ImageOS` | `ubuntu24`, as on GitHub's `ubuntu-24.04` image | | |
| 392 | +| `ImageVersion` | `20250101.1` | | |
| 393 | + | |
| 394 | +Set only in some cases: | |
| 395 | + | |
| 396 | +| Variable | When | | |
| 397 | +| --- | --- | | |
| 398 | +| `RUNNER_DEBUG` | `1` when the secret or variable `ACTIONS_STEP_DEBUG` or `ACTIONS_RUNNER_DEBUG` is `true` | | |
| 399 | +| `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_RUNTIME_URL`, `ACTIONS_RESULTS_URL`, `ACTIONS_CACHE_URL`, `ACTIONS_CACHE_SERVICE_V2` | In JavaScript actions, Docker container actions and the built-in actions only, never in `run:` steps. They give access to the job's cache and artifact services and stop working when the job ends. See [Caching and artifacts](/docs/actions/caching-and-artifacts#the-runtime-services). | | |
| 400 | +| `ACTIONS_ID_TOKEN_REQUEST_URL`, `ACTIONS_ID_TOKEN_REQUEST_TOKEN` | When the job has `permissions: id-token: write`. Used by OIDC clients; see [OIDC](/docs/actions/oidc). | | |
| 401 | +| `GITHUB_ACTION_PATH`, `GITHUB_ACTION_REPOSITORY`, `GITHUB_ACTION_REF` | In JavaScript and Docker actions, and in the steps of composite actions | | |
| 402 | +| `INPUT_<NAME>` | In JavaScript and Docker actions: each input, with the name upper-cased and spaces replaced by `_` | | |
| 403 | +| `STATE_<NAME>` | In an action's main and post scripts: values saved with `$GITHUB_STATE` or `::save-state` | | |
| 404 | + | |
| 405 | +Hosted runners also set `LANG=C.UTF-8`, `LC_ALL=C.UTF-8`, `PIP_BREAK_SYSTEM_PACKAGES=1` (so `pip install` works with Ubuntu's Python) and `COREPACK_ENABLE_DOWNLOAD_PROMPT=0`. | |
| 406 | + | |
| 407 | +GitHub variables that Diffinery doesn't set include `GITHUB_ACTOR_ID`, `GITHUB_REPOSITORY_ID`, `GITHUB_REPOSITORY_OWNER_ID`, `GITHUB_WORKFLOW_SHA` and `GITHUB_REF_PROTECTED`. `GITHUB_TOKEN` isn't an environment variable unless you set it, as on GitHub: | |
| 408 | + | |
| 409 | +```yaml | |
| 410 | +- run: gh api repos/${{ github.repository }} | |
| 411 | + env: | |
| 412 | + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| 413 | +``` | |
| 414 | + | |
| 415 | +## Differences from GitHub | |
| 416 | + | |
| 417 | +- `jobs.<id>.environment.url` can't use step outputs. | |
| 418 | +- Job-level `if:` can also read the workflow-level `env`. | |
| 419 | +- `hashFiles()` returns `''` outside steps and other runner-evaluated values. | |
| 420 | +- `github.event.inputs` keeps the types of dispatch inputs; on GitHub its values are strings. | |
| 421 | +- `github.run_number` counts runs across all workflows of the repository, and `github.run_id` is the same number. | |
| 422 | +- On a re-run, `github.actor` is whoever started the re-run (and `github.triggering_actor` matches it). | |
| 423 | +- `github.ref_protected` is always `false`, and `secrets.DIFFINERY_TOKEN` is an extra name for the job token. | |
| 424 | +- `GITHUB_*` and `RUNNER_*` defaults can't be overridden, but `CI` and `DIFFINERY` can. |
addedapps/diffinery/docs/actions/oidc.md+369−0View file
| @@ -1,0 +1,369 @@ | ||
| 1 | +--- | |
| 2 | +title: OpenID Connect (OIDC) | |
| 3 | +description: Let workflows authenticate to AWS, Google Cloud and your own services with short-lived OIDC tokens issued by Diffinery, instead of stored keys. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +A workflow job can ask Diffinery for an OpenID Connect ID token: a signed JWT that says which repository, branch, commit and workflow the job belongs to. A cloud provider or your own service that trusts Diffinery's issuer can exchange that token for short-lived credentials, so you don't store long-lived cloud keys as secrets. This works like GitHub Actions' OIDC, with Diffinery as the issuer; the differences are listed in [Differences from GitHub](#differences-from-github). | |
| 7 | + | |
| 8 | +## Requirements | |
| 9 | + | |
| 10 | +- The job's effective permissions must include `id-token: write`. A job's own `permissions:` replaces the workflow's top-level `permissions:` entirely, as on GitHub, so a job that sets `permissions:` must list `id-token: write` itself. `permissions: write-all` includes it. | |
| 11 | +- The repository's default permissions never include `id-token`, and neither does `read-all`. `id-token: read` grants nothing. | |
| 12 | +- It works on hosted and self-hosted runners. | |
| 13 | +- In a reusable workflow, the called jobs get the calling job's permissions, and anything the called workflow or its jobs declare is lowered to the calling job's permissions. So the calling job's effective permissions must include `id-token: write`, and so must the called workflow's or job's `permissions:` when it sets one. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions#reusable-workflows). | |
| 14 | + | |
| 15 | +```yaml | |
| 16 | +permissions: | |
| 17 | + id-token: write | |
| 18 | + contents: read | |
| 19 | +``` | |
| 20 | + | |
| 21 | +This job gets no OIDC token, because its own `permissions:` replaces the workflow's: | |
| 22 | + | |
| 23 | +```yaml | |
| 24 | +permissions: | |
| 25 | + id-token: write | |
| 26 | + contents: read | |
| 27 | + | |
| 28 | +jobs: | |
| 29 | + deploy: | |
| 30 | + runs-on: ubuntu-latest | |
| 31 | + permissions: | |
| 32 | + contents: read # id-token is now none for this job | |
| 33 | + steps: | |
| 34 | + - run: echo "no ACTIONS_ID_TOKEN_REQUEST_URL here" | |
| 35 | +``` | |
| 36 | + | |
| 37 | +Without the permission, the variables below aren't set, and `core.getIDToken()` in an action fails because `ACTIONS_ID_TOKEN_REQUEST_URL` is missing. | |
| 38 | + | |
| 39 | +## The issuer | |
| 40 | + | |
| 41 | +| Item | Value | | |
| 42 | +|---|---| | |
| 43 | +| Issuer (`iss`) | `https://diffinery.com` | | |
| 44 | +| Discovery document | `https://diffinery.com/.well-known/openid-configuration` | | |
| 45 | +| Signing keys (JWKS) | `https://diffinery.com/.well-known/jwks` | | |
| 46 | +| Algorithm | `ES256` (ECDSA P-256 with SHA-256) | | |
| 47 | +| Token lifetime | 10 minutes | | |
| 48 | + | |
| 49 | +The discovery document: | |
| 50 | + | |
| 51 | +```json | |
| 52 | +{ | |
| 53 | + "issuer": "https://diffinery.com", | |
| 54 | + "jwks_uri": "https://diffinery.com/.well-known/jwks", | |
| 55 | + "response_types_supported": ["id_token"], | |
| 56 | + "subject_types_supported": ["public"], | |
| 57 | + "id_token_signing_alg_values_supported": ["ES256"], | |
| 58 | + "claims_supported": ["sub", "aud", "iss", "repository", "repository_id", "repository_owner", "repository_owner_id", "environment", "agent_origin", "ref", "ref_type", "sha", "workflow", "event_name", "actor", "actor_id", "run_id"] | |
| 59 | +} | |
| 60 | +``` | |
| 61 | + | |
| 62 | +The JWKS document holds the public key as an EC JWK with `"use": "sig"`, `"alg": "ES256"` and a `kid`, which matches the `kid` in each token's header. | |
| 63 | + | |
| 64 | +## Requesting a token | |
| 65 | + | |
| 66 | +When a job has `id-token: write`, the runner sets two environment variables, the same ones GitHub sets: | |
| 67 | + | |
| 68 | +| Variable | Value | | |
| 69 | +|---|---| | |
| 70 | +| `ACTIONS_ID_TOKEN_REQUEST_URL` | `https://diffinery.com/runner/jobs/<job id>/oidc?api-version=2.0` | | |
| 71 | +| `ACTIONS_ID_TOKEN_REQUEST_TOKEN` | The job's token, to send as a bearer token | | |
| 72 | + | |
| 73 | +Actions that call `core.getIDToken(audience)` from `@actions/core`, such as `aws-actions/configure-aws-credentials` and `google-github-actions/auth`, use them automatically. From a shell step, append `&audience=<value>` and send a `GET` (or `POST`): | |
| 74 | + | |
| 75 | +```yaml | |
| 76 | +- name: Get an OIDC token | |
| 77 | + run: | | |
| 78 | + token=$(curl -fsS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ | |
| 79 | + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://deploy.example.com" | jq -r .value) | |
| 80 | + echo "::add-mask::$token" | |
| 81 | + echo "OIDC_TOKEN=$token" >> "$GITHUB_ENV" | |
| 82 | +``` | |
| 83 | + | |
| 84 | +The response is `{"value": "<JWT>"}`. Without `audience`, the token's `aud` is `https://diffinery.com`. Mask the token with `::add-mask::` before doing anything else with it: it isn't a stored secret, so the runner doesn't know to hide it. | |
| 85 | + | |
| 86 | +Errors from the endpoint: | |
| 87 | + | |
| 88 | +| Status and message | Cause | | |
| 89 | +|---|---| | |
| 90 | +| `403 this job lacks permissions: id-token: write` | The job doesn't have the permission. | | |
| 91 | +| `409 job is not running` | The job has finished, or hasn't started. | | |
| 92 | +| `401 job token required` / `401 token does not match job` | The request didn't send this job's token. | | |
| 93 | +| `501 OIDC is not configured on this Diffinery instance` | The instance has no signing key. A deploy creates one (`OIDC_PRIVATE_KEY`, see [Worker secrets](/docs/self-hosting/configuration#worker-secrets)); ask the instance's administrators. | | |
| 94 | + | |
| 95 | +## Claims | |
| 96 | + | |
| 97 | +Every token has these claims. All values are strings except the timestamps. | |
| 98 | + | |
| 99 | +| Claim | Example | Meaning | | |
| 100 | +|---|---|---| | |
| 101 | +| `iss` | `https://diffinery.com` | Issuer. | | |
| 102 | +| `aud` | `sts.amazonaws.com` | The requested audience, or `https://diffinery.com` if none was given. A single string. | | |
| 103 | +| `sub` | `repo:acme/website:ref:refs/heads/main` | As on GitHub: `repo:<owner>/<repo>:environment:<name>` for a job with `environment:`, `repo:<owner>/<repo>:pull_request` for a `pull_request` run, and `repo:<owner>/<repo>:ref:<ref>` otherwise. | | |
| 104 | +| `iat`, `nbf` | `1760000000` | Issue time and not-before time (the same). | | |
| 105 | +| `exp` | `1760000600` | Expiry, 10 minutes after issue. | | |
| 106 | +| `repository` | `acme/website` | Repository that runs the workflow. | | |
| 107 | +| `repository_id` | `2918374650` | The repository's numeric id. It stays the same when the repository is renamed or transferred, and a new repository with an old name gets a new one. | | |
| 108 | +| `repository_owner` | `acme` | Its owner (user or organization). | | |
| 109 | +| `repository_owner_id` | `3482910375` | The owner's numeric id, which a new account with a reused name doesn't get. | | |
| 110 | +| `environment` | `production` | The environment the job deployed to. Only on jobs with `environment:`, once its protection rules passed, and only when the job may also have the environment's secrets. | | |
| 111 | +| `agent_origin` | `false` | Diffinery's own claim: `true` when the code the run executes came from an agent (an agent pushed it, or merged it). | | |
| 112 | +| `ref_type` | `branch` | `branch` or `tag`. | | |
| 113 | +| `head_ref`, `base_ref` | `feature`, `main` | The pull request's branches, on pull request runs only. | | |
| 114 | +| `run_number`, `run_attempt` | `128`, `1` | The run's number and attempt. | | |
| 115 | +| `ref` | `refs/heads/main` | The run's ref. See the table below. | | |
| 116 | +| `sha` | `9fceb02d0ae598e95dc970b74767f19372d61af8` | The commit the run checked out. | | |
| 117 | +| `workflow` | `Deploy` | The workflow's `name`, or its file path when it has none. | | |
| 118 | +| `event_name` | `push` | The event that started the run. | | |
| 119 | +| `run_id` | `128` | The run number, as a string. It's the same value as `github.run_id`, and stays the same when the run is re-run. | | |
| 120 | +| `actor` | `diffy` | Who started the run. | | |
| 121 | +| `actor_id` | `1938475620` | Their numeric id. | | |
| 122 | +| `job_workflow_ref` | `acme/website/.github/workflows/deploy.yml@refs/heads/main` | The run's top-level workflow file and ref. | | |
| 123 | + | |
| 124 | +The token header is `{"alg": "ES256", "typ": "JWT", "kid": "..."}`. | |
| 125 | + | |
| 126 | +The `ref` for each event. It is also the end of `sub`, except in a job with `environment:` and in a `pull_request` run: | |
| 127 | + | |
| 128 | +| Event | `ref` | | |
| 129 | +|---|---| | |
| 130 | +| `push` | The pushed branch or tag, for example `refs/heads/main` or `refs/tags/v1.2.0` | | |
| 131 | +| `pull_request` | `refs/pull/<number>/merge` | | |
| 132 | +| `pull_request_target` | The base branch, for example `refs/heads/main` | | |
| 133 | +| `merge_group` | The merge queue's temporary branch | | |
| 134 | +| `workflow_dispatch` | The branch or tag it was started on | | |
| 135 | +| `schedule`, `workflow_run`, `repository_dispatch` | The default branch | | |
| 136 | +| `release` | The release's tag, for example `refs/tags/v1.4.0` | | |
| 137 | + | |
| 138 | +> **Important:** `pull_request_target` runs carry the base branch's `ref` and `sub`, and so do `schedule`, `workflow_run` and `repository_dispatch` runs on the default branch. A trust policy that only checks `sub` treats them like pushes to that branch. They run the base branch's own workflow files, so this is safe unless such a workflow checks out and runs pull request code. Where your provider lets you, also check `event_name`. | |
| 139 | + | |
| 140 | +## Jobs that use an environment | |
| 141 | + | |
| 142 | +A job with `environment:` gets GitHub's environment form of `sub`, `repo:<owner>/<repo>:environment:<name>`, and an `environment` claim. The token is only issued after the environment's deployment branch policy has passed, so a trust policy on `repo:acme/website:environment:production` only trusts jobs that were allowed to deploy to `production` (see [Deployments and environments](/docs/actions/environments)). A job that the environment lets deploy but keeps its secrets from, such as agent code on a branch the policy doesn't trust agents with, gets the `ref:` or `pull_request` subject instead. To keep agent code out of a role altogether, also require `agent_origin` to be `false`. | |
| 143 | + | |
| 144 | +To keep trusting a reused name out, also check `repository_id` and `repository_owner_id` where your provider lets you: a repository deleted and recreated, or an owner name taken by a new account, gets new ids. | |
| 145 | + | |
| 146 | +## AWS | |
| 147 | + | |
| 148 | +AWS accepts Diffinery tokens through an IAM OIDC identity provider and a role whose trust policy names it. | |
| 149 | + | |
| 150 | +1. Create the identity provider (once per AWS account), with provider URL `https://diffinery.com` and audience `sts.amazonaws.com`: | |
| 151 | + | |
| 152 | + ```sh | |
| 153 | + aws iam create-open-id-connect-provider \ | |
| 154 | + --url https://diffinery.com \ | |
| 155 | + --client-id-list sts.amazonaws.com | |
| 156 | + ``` | |
| 157 | + | |
| 158 | +2. Create a role with this trust policy. Replace the account id, and narrow `sub` to the repositories and branches that may deploy: | |
| 159 | + | |
| 160 | + ```json | |
| 161 | + { | |
| 162 | + "Version": "2012-10-17", | |
| 163 | + "Statement": [ | |
| 164 | + { | |
| 165 | + "Effect": "Allow", | |
| 166 | + "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/diffinery.com" }, | |
| 167 | + "Action": "sts:AssumeRoleWithWebIdentity", | |
| 168 | + "Condition": { | |
| 169 | + "StringEquals": { | |
| 170 | + "diffinery.com:aud": "sts.amazonaws.com", | |
| 171 | + "diffinery.com:sub": "repo:acme/website:ref:refs/heads/main" | |
| 172 | + } | |
| 173 | + } | |
| 174 | + } | |
| 175 | + ] | |
| 176 | + } | |
| 177 | + ``` | |
| 178 | + | |
| 179 | + To allow every branch and tag of the repository, use `"StringLike": { "diffinery.com:sub": "repo:acme/website:*" }` together with the `aud` check. That pattern also matches `pull_request` runs and jobs in any environment, so a role that deploys should name one `ref:` or `environment:` subject instead. Attach the permissions the workflow needs to the role. | |
| 180 | + | |
| 181 | +3. Use it from a workflow: | |
| 182 | + | |
| 183 | + ```yaml | |
| 184 | + name: Deploy to AWS | |
| 185 | + on: | |
| 186 | + push: | |
| 187 | + branches: [main] | |
| 188 | + | |
| 189 | + permissions: | |
| 190 | + id-token: write | |
| 191 | + contents: read | |
| 192 | + | |
| 193 | + jobs: | |
| 194 | + deploy: | |
| 195 | + runs-on: ubuntu-latest | |
| 196 | + steps: | |
| 197 | + - uses: actions/checkout@v4 | |
| 198 | + - uses: aws-actions/configure-aws-credentials@v4 | |
| 199 | + with: | |
| 200 | + role-to-assume: arn:aws:iam::123456789012:role/diffinery-website-deploy | |
| 201 | + aws-region: eu-west-1 | |
| 202 | + - run: pip install awscli # the AWS CLI isn't preinstalled on hosted runners | |
| 203 | + - run: aws sts get-caller-identity | |
| 204 | + - run: aws s3 sync ./dist s3://acme-website --delete | |
| 205 | + ``` | |
| 206 | + | |
| 207 | +Without the action, exchange the token yourself: | |
| 208 | + | |
| 209 | +```sh | |
| 210 | +token=$(curl -fsS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ | |
| 211 | + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value) | |
| 212 | +aws sts assume-role-with-web-identity \ | |
| 213 | + --role-arn arn:aws:iam::123456789012:role/diffinery-website-deploy \ | |
| 214 | + --role-session-name "diffinery-$GITHUB_RUN_ID" \ | |
| 215 | + --web-identity-token "$token" | |
| 216 | +``` | |
| 217 | + | |
| 218 | +## Google Cloud | |
| 219 | + | |
| 220 | +Google Cloud accepts Diffinery tokens through Workload Identity Federation. | |
| 221 | + | |
| 222 | +1. Create a pool and an OIDC provider for the Diffinery issuer. The attribute condition limits which tokens are accepted at all: | |
| 223 | + | |
| 224 | + ```sh | |
| 225 | + gcloud iam workload-identity-pools create diffinery \ | |
| 226 | + --location=global --display-name="Diffinery" | |
| 227 | + | |
| 228 | + gcloud iam workload-identity-pools providers create-oidc diffinery \ | |
| 229 | + --location=global \ | |
| 230 | + --workload-identity-pool=diffinery \ | |
| 231 | + --issuer-uri=https://diffinery.com \ | |
| 232 | + --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref,attribute.event_name=assertion.event_name" \ | |
| 233 | + --attribute-condition="assertion.repository_owner == 'acme'" | |
| 234 | + ``` | |
| 235 | + | |
| 236 | +2. Let one repository impersonate a service account (use your project number): | |
| 237 | + | |
| 238 | + ```sh | |
| 239 | + gcloud iam service-accounts add-iam-policy-binding deployer@acme-prod.iam.gserviceaccount.com \ | |
| 240 | + --role=roles/iam.workloadIdentityUser \ | |
| 241 | + --member="principalSet://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/diffinery/attribute.repository/acme/website" | |
| 242 | + ``` | |
| 243 | + | |
| 244 | +3. Use it from a workflow: | |
| 245 | + | |
| 246 | + ```yaml | |
| 247 | + permissions: | |
| 248 | + id-token: write | |
| 249 | + contents: read | |
| 250 | + | |
| 251 | + jobs: | |
| 252 | + deploy: | |
| 253 | + runs-on: ubuntu-latest | |
| 254 | + steps: | |
| 255 | + - uses: actions/checkout@v4 | |
| 256 | + - uses: google-github-actions/auth@v2 | |
| 257 | + with: | |
| 258 | + workload_identity_provider: projects/123456789012/locations/global/workloadIdentityPools/diffinery/providers/diffinery | |
| 259 | + service_account: deployer@acme-prod.iam.gserviceaccount.com | |
| 260 | + - uses: google-github-actions/setup-gcloud@v2 | |
| 261 | + - run: gcloud run deploy website --source . --region europe-west1 | |
| 262 | + ``` | |
| 263 | + | |
| 264 | +## Your own service on Cloudflare Workers | |
| 265 | + | |
| 266 | +OIDC tokens also let a workflow call a service you run, such as a deploy hook on Cloudflare Workers, without a shared secret. The Worker verifies the token against Diffinery's published keys: | |
| 267 | + | |
| 268 | +```ts | |
| 269 | +import { createRemoteJWKSet, jwtVerify } from "jose"; | |
| 270 | + | |
| 271 | +const JWKS = createRemoteJWKSet(new URL("https://diffinery.com/.well-known/jwks")); | |
| 272 | + | |
| 273 | +export default { | |
| 274 | + async fetch(request: Request): Promise<Response> { | |
| 275 | + const token = request.headers.get("authorization")?.replace(/^Bearer\s+/i, ""); | |
| 276 | + if (!token) return new Response("missing token", { status: 401 }); | |
| 277 | + try { | |
| 278 | + const { payload } = await jwtVerify(token, JWKS, { | |
| 279 | + issuer: "https://diffinery.com", | |
| 280 | + audience: "https://deploy.acme.workers.dev", | |
| 281 | + algorithms: ["ES256"], | |
| 282 | + }); | |
| 283 | + if (payload.repository !== "acme/website" || payload.ref !== "refs/heads/main" || payload.event_name !== "push") { | |
| 284 | + return new Response("not allowed", { status: 403 }); | |
| 285 | + } | |
| 286 | + // ... do the deploy for payload.sha ... | |
| 287 | + return Response.json({ ok: true, sha: payload.sha }); | |
| 288 | + } catch { | |
| 289 | + return new Response("invalid token", { status: 401 }); | |
| 290 | + } | |
| 291 | + }, | |
| 292 | +}; | |
| 293 | +``` | |
| 294 | + | |
| 295 | +The workflow requests a token with the same audience and sends it: | |
| 296 | + | |
| 297 | +```yaml | |
| 298 | +permissions: | |
| 299 | + id-token: write | |
| 300 | + | |
| 301 | +jobs: | |
| 302 | + deploy: | |
| 303 | + runs-on: ubuntu-latest | |
| 304 | + steps: | |
| 305 | + - run: | | |
| 306 | + token=$(curl -fsS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ | |
| 307 | + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://deploy.acme.workers.dev" | jq -r .value) | |
| 308 | + echo "::add-mask::$token" | |
| 309 | + curl -fsS -X POST https://deploy.acme.workers.dev/ -H "Authorization: Bearer $token" | |
| 310 | +``` | |
| 311 | + | |
| 312 | +## Verifying tokens yourself | |
| 313 | + | |
| 314 | +Any service can verify a Diffinery token. Check, in this order: | |
| 315 | + | |
| 316 | +1. The header's `alg` is `ES256`. Reject anything else. | |
| 317 | +2. The signature, with the key from `https://diffinery.com/.well-known/jwks` whose `kid` matches the header. Cache the key set, and fetch it again when you see an unknown `kid`. | |
| 318 | +3. `iss` is exactly `https://diffinery.com`. | |
| 319 | +4. `aud` is the audience your service expects. Have workflows request an audience unique to your service, so a token meant for another service can't be replayed to yours. | |
| 320 | +5. `exp` is in the future and `nbf` is in the past (allow a little clock skew). | |
| 321 | +6. Your authorization rules: `repository`, `ref`, `event_name`, and if needed `sha` or `actor`. Match `repository` exactly, not by prefix. | |
| 322 | + | |
| 323 | +In Node.js with [jose](https://github.com/panva/jose), `jwtVerify` with `issuer`, `audience` and `algorithms: ["ES256"]` performs steps 1 to 5 (see the Worker example above). In Python with PyJWT: | |
| 324 | + | |
| 325 | +```text | |
| 326 | +pip install "pyjwt[crypto]" | |
| 327 | +``` | |
| 328 | + | |
| 329 | +```text | |
| 330 | +import jwt | |
| 331 | + | |
| 332 | +jwks = jwt.PyJWKClient("https://diffinery.com/.well-known/jwks") | |
| 333 | + | |
| 334 | +def verify(token: str) -> dict: | |
| 335 | + key = jwks.get_signing_key_from_jwt(token).key | |
| 336 | + claims = jwt.decode( | |
| 337 | + token, | |
| 338 | + key, | |
| 339 | + algorithms=["ES256"], | |
| 340 | + audience="https://deploy.example.com", | |
| 341 | + issuer="https://diffinery.com", | |
| 342 | + ) | |
| 343 | + if claims["repository"] != "acme/website" or claims["ref"] != "refs/heads/main": | |
| 344 | + raise PermissionError("not allowed") | |
| 345 | + return claims | |
| 346 | +``` | |
| 347 | + | |
| 348 | +## Differences from GitHub | |
| 349 | + | |
| 350 | +| Area | GitHub | Diffinery | | |
| 351 | +|---|---|---| | |
| 352 | +| Issuer | `https://token.actions.githubusercontent.com` | `https://diffinery.com` | | |
| 353 | +| Algorithm | RS256 | ES256 | | |
| 354 | +| `sub` | `environment:<name>`, `pull_request` or `ref:<ref>` form, and a customizable template | The same three forms; the template can't be customized | | |
| 355 | +| Claims | Many, including `workflow_ref`, `job_workflow_sha`, `runner_environment` | Only the claims listed in [Claims](#claims) | | |
| 356 | +| `job_workflow_ref` | The reusable workflow's file when the job is in one | Always the run's top-level workflow | | |
| 357 | +| Token lifetime | About 5 to 10 minutes | 10 minutes | | |
| 358 | + | |
| 359 | +When you port a trust policy from GitHub, change the issuer host (`token.actions.githubusercontent.com` to `diffinery.com`) in provider URLs and condition keys, and keep your `sub` patterns as they are, unless they rely on a customized subject template. | |
| 360 | + | |
| 361 | +## Troubleshooting | |
| 362 | + | |
| 363 | +| Problem | Fix | | |
| 364 | +|---|---| | |
| 365 | +| `Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable` from an action | The job's effective permissions lack `id-token: write`. Add it to the job's `permissions:` if the job has one, otherwise to the workflow's. In a reusable workflow, the calling job needs it too. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions). | | |
| 366 | +| AWS: `Not authorized to perform sts:AssumeRoleWithWebIdentity` | The trust policy's `sub` or `aud` doesn't match. Decode the token (its middle part is base64url JSON) and compare `sub` and `aud` with the policy. | | |
| 367 | +| AWS: `No OpenIDConnect provider found in your account for https://diffinery.com` | Create the identity provider with URL `https://diffinery.com`. | | |
| 368 | +| Google Cloud: `The given credential is rejected by the attribute condition` | The `attribute-condition` on the provider doesn't match the token's claims. | | |
| 369 | +| Your service rejects the token as expired | Tokens last 10 minutes. Request a new one right before you use it. | |
addedapps/diffinery/docs/actions/overview.md+467−0View file
| @@ -1,0 +1,467 @@ | ||
| 1 | +--- | |
| 2 | +title: Actions overview | |
| 3 | +description: How Diffinery Actions runs GitHub Actions workflows on Cloudflare, and how to start, watch, cancel and re-run runs from the web UI, CLI, API and MCP. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +Diffinery Actions is the CI system built into every Diffinery repository. It reads GitHub Actions workflow files from your repository, runs their jobs in Linux containers on Cloudflare (or on your own machines), and reports the results as checks on commits and pull requests. Use it to build, test and deploy your code on push, on pull requests, on a schedule or on demand. | |
| 7 | + | |
| 8 | +## Key concepts | |
| 9 | + | |
| 10 | +| Term | Meaning | | |
| 11 | +| --- | --- | | |
| 12 | +| Workflow | A YAML file in `.github/workflows/` or `.diffinery/workflows/` that says when to run (`on:`) and what to run (`jobs:`). The syntax is GitHub Actions syntax. See [Workflow syntax](/docs/actions/workflow-syntax). | | |
| 13 | +| Event | Something that happens in the repository and can start a workflow: a push, a pull request update, a merge queue group, a manual dispatch, a schedule, another workflow's run, a published release or a `repository_dispatch` call. See [Events that trigger workflows](/docs/actions/events). | | |
| 14 | +| Run | One execution of one workflow for one event. A run has a number, a status and a conclusion, and holds one or more jobs. | | |
| 15 | +| Job | A set of steps that runs on one runner. Jobs run in parallel unless they depend on each other with `needs`. A matrix turns one job definition into several jobs. | | |
| 16 | +| Step | One command (`run:`) or one action (`uses:`) inside a job. Steps run in order in the same workspace. | | |
| 17 | +| Runner | The machine that executes a job. Diffinery-hosted runners are Cloudflare Containers; self-hosted runners are machines you connect with `diffinery runner`. See [Runners](/docs/actions/runners). | | |
| 18 | +| Check | The result of one job, reported on the commit the run is about. Branch protection can require checks to pass before merging. | | |
| 19 | +| Environment | A named deployment target, such as `production`, with its own secrets, variables and branch policy. A job that sets `environment:` gets them and records a deployment. See [Environments](/docs/actions/environments). | | |
| 20 | + | |
| 21 | +Workflows are compatible with GitHub Actions: the same triggers, expressions, matrices, `needs`, outputs, caches, artifacts, secrets, environments, OIDC, a `GITHUB_TOKEN` that follows `permissions:`, the official actions (`actions/checkout`, `actions/cache`, `actions/upload-artifact`, `actions/download-artifact`, `actions/github-script`, `actions/setup-node`, `actions/setup-python`, `actions/setup-go` and the rest), and remote JavaScript, composite and Docker actions. Differences are called out on each page and collected in [GitHub Actions compatibility](/docs/actions/compatibility). | |
| 22 | + | |
| 23 | +## Your first workflow | |
| 24 | + | |
| 25 | +You need write access to the repository so you can push. This example runs the tests of a Node.js project on every push to `main` and on every pull request. | |
| 26 | + | |
| 27 | +1. In a clone of `acme/website`, create `.github/workflows/ci.yml`: | |
| 28 | + | |
| 29 | + ```yaml | |
| 30 | + name: CI | |
| 31 | + on: | |
| 32 | + push: | |
| 33 | + branches: [main] | |
| 34 | + pull_request: | |
| 35 | + jobs: | |
| 36 | + test: | |
| 37 | + runs-on: ubuntu-latest | |
| 38 | + steps: | |
| 39 | + - uses: actions/checkout@v4 | |
| 40 | + - uses: actions/setup-node@v4 | |
| 41 | + with: | |
| 42 | + node-version: 22 | |
| 43 | + cache: npm | |
| 44 | + - run: npm ci | |
| 45 | + - run: npm test | |
| 46 | + - name: Write a summary | |
| 47 | + run: echo "### Tests passed on \`${{ github.ref_name }}\`" >> "$GITHUB_STEP_SUMMARY" | |
| 48 | + ``` | |
| 49 | + | |
| 50 | +2. Check the file before you push. The CLI uses the same parser as the server: | |
| 51 | + | |
| 52 | + ```sh | |
| 53 | + diffinery workflow validate .github/workflows/ci.yml | |
| 54 | + ``` | |
| 55 | + | |
| 56 | +3. Commit and push: | |
| 57 | + | |
| 58 | + ```sh | |
| 59 | + git add .github/workflows/ci.yml | |
| 60 | + git commit -m "Add CI" | |
| 61 | + git push origin main | |
| 62 | + ``` | |
| 63 | + | |
| 64 | +4. Open `https://diffinery.com/acme/website/actions`. A run titled `Add CI` (the first line of the commit message) appears under **All workflows** and under **CI** in the sidebar. Click it to see its jobs, then click the `test` job to follow its log live. | |
| 65 | + | |
| 66 | +5. Or follow it from your terminal: | |
| 67 | + | |
| 68 | + ```sh | |
| 69 | + diffinery run list -R acme/website --limit 1 | |
| 70 | + diffinery run watch <run-id> -R acme/website | |
| 71 | + ``` | |
| 72 | + | |
| 73 | +When the job finishes, the commit shows a check named `CI / test`. On a pull request, the same check appears on the pull request's head commit. | |
| 74 | + | |
| 75 | +If you don't have the CLI yet, install it and sign in through your browser: | |
| 76 | + | |
| 77 | +```sh | |
| 78 | +curl -fsSL https://diffinery.com/install | bash | |
| 79 | +diffinery login | |
| 80 | +``` | |
| 81 | + | |
| 82 | +## Where workflow files live | |
| 83 | + | |
| 84 | +Diffinery reads workflow files from two directories at the root of the repository: | |
| 85 | + | |
| 86 | +- `.github/workflows/` | |
| 87 | +- `.diffinery/workflows/` | |
| 88 | + | |
| 89 | +Every file directly in one of these directories whose name ends in `.yml` or `.yaml` is a workflow. Files in subdirectories are ignored. There is no precedence between the two directories: workflows from both are loaded (`.github/workflows/` first), each one is its own workflow identified by its path, and each one that matches an event starts its own run. Use `.diffinery/workflows/` for workflows that only make sense on Diffinery, so a GitHub mirror of the repository doesn't try to run them. | |
| 90 | + | |
| 91 | +When you name a workflow by file name rather than path (for example `deploy.yml` in `diffinery workflow run deploy.yml`), Diffinery picks the first file whose path is, or ends with `/deploy.yml`, so `.github/workflows/deploy.yml` wins over `.diffinery/workflows/deploy.yml`. Pass the full path to pick the other one. | |
| 92 | + | |
| 93 | +Which version of a file runs depends on the event: | |
| 94 | + | |
| 95 | +| Event | Workflow files are read from | | |
| 96 | +| --- | --- | | |
| 97 | +| `push` | The pushed commit | | |
| 98 | +| `pull_request` | The pull request's test merge commit (the head merged into the base) | | |
| 99 | +| `pull_request_target` | The base branch's current commit | | |
| 100 | +| `merge_group` | The merge group commit | | |
| 101 | +| `workflow_dispatch` | The branch or tag you choose | | |
| 102 | +| `schedule`, `workflow_run`, `repository_dispatch` | The default branch's current commit | | |
| 103 | +| `release` | The commit the release's tag points to | | |
| 104 | + | |
| 105 | +The workflow list in the **Actions** sidebar and `GET /repos/{owner}/{repo}/actions/workflows` show the workflows on the default branch. | |
| 106 | + | |
| 107 | +## How a run executes | |
| 108 | + | |
| 109 | +1. **An event happens.** Diffinery loads the workflow files from the commit the event is about (see the table above) and checks each workflow's `on:` section, including branch, tag and path filters. | |
| 110 | +2. **A run is created** for each matching workflow, with status `queued`. It gets the next run number and a title: the `run-name` if the workflow sets one, otherwise the first line of the commit message (push), the pull request title (pull request events), `Merge group for #<n>: <title>` (merge groups), the release's name or tag (release), the `event_type` (repository_dispatch) or the workflow name (dispatch, schedule, workflow_run). If the workflow has a `concurrency` group, other runs in the group may be cancelled or this run may wait (see [Concurrency](#concurrency)). | |
| 111 | +3. **The run is orchestrated durably** by a Cloudflare Workflow. It walks the job graph from `needs`: each job starts as soon as the jobs it needs have finished, without waiting for unrelated jobs. The orchestration survives restarts of the platform, and each wait for a job lasts at most 6 hours. | |
| 112 | +4. **Each job is evaluated** just before it starts: its `if:` condition and its matrix. A job whose condition is false is recorded as `skipped`; without a status function in its `if:`, a job is also skipped when any job above it in the `needs` chain failed, was cancelled or was skipped. A matrix job becomes one job per combination (at most 256), started up to `max-parallel` at a time, and each combination gets its own `name`, `runs-on`, `timeout-minutes`, `continue-on-error` and job `concurrency` group, evaluated with its `matrix` values. A job with a `concurrency` group waits as `pending` while another job of the group runs. | |
| 113 | +5. **Each hosted job gets its own container.** A fresh Cloudflare Container (Ubuntu 24.04, linux/amd64) starts with a job token that is valid only for that job. The runner agent inside fetches the job definition (for a job with `environment:`, this is when the environment's branch policy is checked and its secrets and variables are added), runs the steps in order, streams the log back as it goes, and reports each step's result and the job's conclusion. The container stops when the job ends. Jobs for self-hosted runners wait until a `diffinery runner` with matching labels claims them instead. | |
| 114 | +6. **The run finishes** when every job is done. Its conclusion is `failure` if any job failed or timed out (except jobs with `continue-on-error: true`), otherwise `cancelled` if the run was cancelled or any job was cancelled, otherwise `skipped` if every job was skipped, otherwise `success`. Skipped jobs don't make a run fail. As on GitHub, a `workflow_run` job with `if: github.event.workflow_run.conclusion == 'success'` is skipped after a run whose jobs were all skipped. | |
| 115 | + | |
| 116 | + If the orchestration loses track of a run whose jobs have all finished (for example, a job's completion signal lost while the platform updates), Diffinery notices 5 minutes after the last job finished and signals the orchestration again. A run still unfinished 30 minutes after its last job, or whose orchestration has stopped, is finished by Diffinery itself, which frees its concurrency group. Jobs the run never started make it fail, and a `Set up workflow` job names them: `The run's workflow stopped after its jobs finished, so a job never started (deploy). Re-run the workflow.` | |
| 117 | + | |
| 118 | +### Timeouts | |
| 119 | + | |
| 120 | +| What | Default | Maximum | | |
| 121 | +| --- | --- | --- | | |
| 122 | +| A job (`jobs.<id>.timeout-minutes`) | 360 minutes | 360 minutes. Larger values are capped at 360. | | |
| 123 | +| A step (`steps[*].timeout-minutes`) | None (the job's timeout applies) | The job's timeout | | |
| 124 | +| Waiting for a self-hosted runner, plus running the job | | 6 hours, after which the job is marked timed out | | |
| 125 | + | |
| 126 | +A job that runs past its `timeout-minutes` is stopped by its runner and fails with `The job exceeded the maximum execution time of <n> minutes.` If Diffinery hears nothing from a job for 6 hours (for example a self-hosted job that no runner picked up, or a runner that disappeared), it stops waiting, ends the job with `The job exceeded its timeout of <n> minutes.` and the conclusion `timed_out`, which the job page shows as **Timed out**. | |
| 127 | + | |
| 128 | +### Run and job states | |
| 129 | + | |
| 130 | +| Status | Meaning | | |
| 131 | +| --- | --- | | |
| 132 | +| `queued` | The run or job has been created and hasn't started. | | |
| 133 | +| `pending` | (Jobs) The job is held back by its job-level `concurrency` group until the group's running job finishes. Shown as **Pending**. | | |
| 134 | +| `waiting` | (Jobs) The job is waiting for its container to start, or for a self-hosted runner to claim it. | | |
| 135 | +| `in_progress` | The run is executing, or a runner has picked up the job. | | |
| 136 | +| `completed` | Finished. See the conclusion. | | |
| 137 | + | |
| 138 | +| Conclusion | Meaning | | |
| 139 | +| --- | --- | | |
| 140 | +| `success` | Every step succeeded (steps with `continue-on-error: true` may have failed). | | |
| 141 | +| `failure` | A step failed, the job could not start, or (for runs) a job without `continue-on-error: true` failed. | | |
| 142 | +| `cancelled` | Someone cancelled the run, a newer run or job in the same concurrency group replaced it, or a sibling matrix job failed or timed out with `fail-fast` on. | | |
| 143 | +| `skipped` | The job's `if:` condition was false, or (for runs) every job was skipped. | | |
| 144 | +| `timed_out` | (Jobs) Diffinery waited 6 hours for the job without a result. | | |
| 145 | + | |
| 146 | +## The Actions tab | |
| 147 | + | |
| 148 | +Anyone who can read the repository can see its runs and logs (anyone at all for a public repository). Starting, cancelling and re-running runs needs write access. | |
| 149 | + | |
| 150 | +### Runs list | |
| 151 | + | |
| 152 | +`https://diffinery.com/acme/website/actions` shows the newest runs first, 25 per page. | |
| 153 | + | |
| 154 | +- The sidebar lists **All workflows** and each workflow on the default branch by name. A workflow with errors shows a failure icon, and its page shows a **This workflow file has errors** box with each error and its line. The **All workflows** page shows **Some workflow files have errors** when any file is invalid. | |
| 155 | +- Filter by branch with the **Branch** menu (**All branches** by default) and by state with the **Status** menu: **Any status**, `queued`, `in progress`, `completed`, `success`, `failure` or `cancelled`. Click **Filter** to apply. | |
| 156 | +- Each row shows the run's title, workflow name, run number, attempt (when greater than 1), triggering event, pull request, actor (with an **agent** badge for agents), ref, age and duration. | |
| 157 | + | |
| 158 | +The list updates live while you watch it. | |
| 159 | + | |
| 160 | +### Run page | |
| 161 | + | |
| 162 | +Click a run to open `https://diffinery.com/acme/website/actions/runs/<run-id>`. The run number works in place of the run ID (for example `.../actions/runs/128`) and opens its latest attempt. It shows: | |
| 163 | + | |
| 164 | +- The run's **Status** and duration, what **Triggered** it and who, the **Commit** and ref (and pull request), and the **Workflow file** at that commit. | |
| 165 | +- One box per job id, listing the jobs it produced (a matrix job says how many, for example `4 jobs (matrix)`), each with its status, duration and, for a failed job, the first line of the failure reason. | |
| 166 | +- **Artifacts**, when jobs uploaded any that haven't expired, with their size and age. Click a name to download it: a `.zip` file, or the file itself for an artifact uploaded with `archive: false`. See [Caching and artifacts](/docs/actions/caching-and-artifacts). | |
| 167 | +- **Cancel run** while the run is not completed (you confirm with **Cancel this run?**), and **Re-run all jobs** once it has completed. Both need write access. A cancelled job on a Diffinery-hosted runner first runs its `if: always()` and `if: cancelled()` steps, for up to 5 minutes (see [Status check functions](/docs/actions/expressions#status-check-functions)); cancelling again stops them. | |
| 168 | + | |
| 169 | +### Job page and logs | |
| 170 | + | |
| 171 | +Click a job to open its log at `https://diffinery.com/acme/website/actions/runs/<run-id>/job/<job-id>`. | |
| 172 | + | |
| 173 | +- The log is split into collapsible sections: **Set up job**, then one per step, then one per post step (for example `Post Run actions/cache@v4`). Failed and running steps open automatically. | |
| 174 | +- While the job runs, new lines stream in live. | |
| 175 | +- A failed job shows a box with **Failed at** the failing step and the reason: the step's own `::error::` message if it printed one, otherwise the exit code and the last lines of its output. Cancelled and timed-out jobs show **Cancelled** or **Timed out**. | |
| 176 | +- **Job summary** renders the Markdown that steps wrote to `$GITHUB_STEP_SUMMARY`. | |
| 177 | +- **Outputs** lists the job's outputs. | |
| 178 | +- **Raw logs** downloads the whole log as plain text, one line per log line prefixed with its timestamp. The page itself shows the first 20,000 lines and links to the full log when there are more. | |
| 179 | + | |
| 180 | +Secret values, and values registered with `::add-mask::`, are replaced by `***` before log lines leave the runner. | |
| 181 | + | |
| 182 | +## Start a workflow manually | |
| 183 | + | |
| 184 | +A workflow with an `on: workflow_dispatch` trigger can be started on demand. You need write access. | |
| 185 | + | |
| 186 | +```yaml | |
| 187 | +name: Deploy | |
| 188 | +on: | |
| 189 | + workflow_dispatch: | |
| 190 | + inputs: | |
| 191 | + environment: | |
| 192 | + description: Where to deploy | |
| 193 | + type: choice | |
| 194 | + options: [staging, production] | |
| 195 | + default: staging | |
| 196 | + dry-run: | |
| 197 | + type: boolean | |
| 198 | + default: true | |
| 199 | +jobs: | |
| 200 | + deploy: | |
| 201 | + runs-on: ubuntu-latest | |
| 202 | + steps: | |
| 203 | + - uses: actions/checkout@v4 | |
| 204 | + - run: ./deploy.sh "${{ inputs.environment }}" "${{ inputs.dry-run }}" | |
| 205 | +``` | |
| 206 | + | |
| 207 | +Input types, defaults and validation are described in [Events that trigger workflows](/docs/actions/events#workflow_dispatch). | |
| 208 | + | |
| 209 | +### In the web UI | |
| 210 | + | |
| 211 | +1. Open the **Actions** tab and click the workflow in the sidebar. | |
| 212 | +2. Expand **Run workflow** (shown when the workflow on the default branch has a `workflow_dispatch` trigger). | |
| 213 | +3. Pick the branch or tag under **Use workflow from**. | |
| 214 | +4. Fill in the inputs. Booleans are checkboxes, `choice` inputs are menus, `number` inputs are number fields, everything else is a text field. Required inputs are marked with `*`. | |
| 215 | +5. Click **Run workflow**. You land on the new run's page. | |
| 216 | + | |
| 217 | +The form is built from the input definitions on the default branch; the run itself uses the workflow file at the ref you pick. | |
| 218 | + | |
| 219 | +### With the CLI | |
| 220 | + | |
| 221 | +```sh | |
| 222 | +diffinery workflow run deploy.yml -R acme/website --ref main \ | |
| 223 | + -f environment=production -f dry-run=false | |
| 224 | +``` | |
| 225 | + | |
| 226 | +| Flag | Meaning | | |
| 227 | +| --- | --- | | |
| 228 | +| `-r, --ref REF` | Branch or tag to run on. Default: the repository's default branch. | | |
| 229 | +| `-f, --field KEY=VALUE` | An input value. Repeat for more inputs. | | |
| 230 | +| `-R, --repo OWNER/NAME` | Repository. Default: inferred from the `origin` remote. | | |
| 231 | +| `--json` | Print the API response as JSON. | | |
| 232 | + | |
| 233 | +The command prints the run ID and URL, and suggests `diffinery run watch <run-id>` to follow it. | |
| 234 | + | |
| 235 | +### With the API | |
| 236 | + | |
| 237 | +Use a token with the write scope (see [Tokens](/docs/accounts/tokens)): | |
| 238 | + | |
| 239 | +```sh | |
| 240 | +curl -X POST https://diffinery.com/api/v3/repos/acme/website/actions/workflows/deploy.yml/dispatches \ | |
| 241 | + -H "Authorization: Bearer dfy_..." \ | |
| 242 | + -H "Content-Type: application/json" \ | |
| 243 | + -d '{"ref": "main", "inputs": {"environment": "production", "dry-run": false}}' | |
| 244 | +``` | |
| 245 | + | |
| 246 | +The workflow can be a file name (`deploy.yml`) or a URL-encoded path (`.github%2Fworkflows%2Fdeploy.yml`). Unlike GitHub, which answers `204 No Content`, Diffinery answers `201 Created` with the new run: | |
| 247 | + | |
| 248 | +```json | |
| 249 | +{ "run_id": "3f9c2a7d0b8e4c1f9a6d5e2b7c4a1f08", "html_url": "https://diffinery.com/acme/website/actions/runs/3f9c2a7d0b8e4c1f9a6d5e2b7c4a1f08" } | |
| 250 | +``` | |
| 251 | + | |
| 252 | +| Status | Message | Cause | | |
| 253 | +| --- | --- | --- | | |
| 254 | +| 403 | `write access is required to run workflows` | Your role or token scope is below write. | | |
| 255 | +| 404 | `workflow deploy.yml not found at main` | No such file at that ref, or the file is invalid (the errors follow a colon). | | |
| 256 | +| 422 | `unknown ref <ref>` | The branch or tag doesn't exist. | | |
| 257 | +| 422 | `workflow does not have a workflow_dispatch trigger` | Add `on: workflow_dispatch` to the file at that ref. | | |
| 258 | +| 422 | `input '<name>' is required` | A required input is missing or empty and has no default. | | |
| 259 | +| 422 | `input '<name>' must be one of <a>, <b>` | A `choice` input got a value that is not in `options`. | | |
| 260 | + | |
| 261 | +### With MCP | |
| 262 | + | |
| 263 | +The `dispatch_workflow` tool takes `owner`, `repo`, `workflow`, an optional `ref` (default: the default branch) and an optional `inputs` object. See [MCP tools](/docs/reference/mcp-tools). | |
| 264 | + | |
| 265 | +## Follow runs and read logs | |
| 266 | + | |
| 267 | +### From the CLI | |
| 268 | + | |
| 269 | +| Command | What it does | | |
| 270 | +| --- | --- | | |
| 271 | +| `diffinery run list` | Lists runs, newest first. Filter with `-b, --branch`, `-w, --workflow` (file name or path), `-s, --status`, `-e, --event`, `-c, --commit` and `-L, --limit` (default 20). | | |
| 272 | +| `diffinery run view <run-id>` | Shows a run and its jobs, with job IDs and failure reasons. | | |
| 273 | +| `diffinery run watch <run-id>` | Polls every 3 seconds (`-i, --interval SECONDS`) and prints each job's status changes. Exits 0 if the run succeeds and 1 otherwise, so you can use it in scripts. On failure it prints the `diffinery run logs` command for the failed job. | | |
| 274 | +| `diffinery run logs <job-id>` | Prints a job's log. With `--json`, prints the log lines as JSON objects with `seq`, `t` (timestamp), `step` (step index, `-1` for job setup) and `text`. | | |
| 275 | +| `diffinery run rerun <run-id>` | Re-runs a run; `--failed` re-runs only its failed jobs, and `--job <job-id>` one job (see [Re-run a run](#re-run-a-run)). | | |
| 276 | +| `diffinery run cancel <run-id>` | Cancels a queued or running run. | | |
| 277 | +| `diffinery workflow list` | Lists the workflows on the default branch, whether each is `active` or `invalid`, and their triggers. | | |
| 278 | +| `diffinery workflow validate [files...]` | Checks workflow files locally (see [Workflow syntax](/docs/actions/workflow-syntax#validate-workflow-files)). | | |
| 279 | + | |
| 280 | +Every command takes `-R acme/website` when you're not inside a clone, and `--json` for machine-readable output. Full flags are in the [CLI reference](/docs/reference/cli). | |
| 281 | + | |
| 282 | +A typical loop for an agent or a script after pushing: | |
| 283 | + | |
| 284 | +```sh | |
| 285 | +run=$(diffinery run list -R acme/website --commit "$(git rev-parse HEAD)" --json | jq -r '.[0].id') | |
| 286 | +diffinery run watch "$run" -R acme/website || { | |
| 287 | + job=$(diffinery run view "$run" -R acme/website --json | jq -r '.jobs[] | select(.conclusion == "failure") | .id' | head -n1) | |
| 288 | + diffinery run logs "$job" -R acme/website | tail -n 100 | |
| 289 | +} | |
| 290 | +``` | |
| 291 | + | |
| 292 | +### From the API | |
| 293 | + | |
| 294 | +| Request | Returns | | |
| 295 | +| --- | --- | | |
| 296 | +| `GET /api/v3/repos/{owner}/{repo}/actions/workflows` | Workflows on the default branch, with `state` (`active` or `invalid`), `errors` and `triggers`. | | |
| 297 | +| `GET /api/v3/repos/{owner}/{repo}/actions/workflows/{workflow}` | One workflow on the default branch, by file name or URL-encoded path. Its `id` is its path. | | |
| 298 | +| `GET /api/v3/repos/{owner}/{repo}/actions/workflows/{workflow}/runs` | That workflow's runs, newest first. Takes `branch`, `status`, `event`, `head_sha`, `per_page` (at most 100, default 30) and `page`. | | |
| 299 | +| `GET /api/v3/repos/{owner}/{repo}/actions/runs` | Runs, newest first. Query parameters: `workflow`, `branch`, `status` (a status or a conclusion), `event`, `head_sha`, `per_page` (at most 100, default 25), `page`. | | |
| 300 | +| `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}` | One run with its `jobs`. | | |
| 301 | +| `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt}` | Attempt `attempt` of the run (each attempt is its own run; see [Re-run a run](#re-run-a-run)). `404 run attempt not found` if there is no such attempt. | | |
| 302 | +| `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/jobs` | The run's jobs, paginated with `per_page` (default 30) and `page`. | | |
| 303 | +| `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/logs` | A zip file with one text file per job (`1_<job name>.txt`, `2_<job name>.txt`, ...), each line prefixed with its timestamp. Holds at most 24 MB of logs; jobs past that are left out. | | |
| 304 | +| `DELETE /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/logs` | Deletes every job's log and step summary for good (`204`); the run, its jobs, checks and artifacts stay. Needs write, and the run must have finished. Agents can delete only the logs of runs that didn't run on the default branch or a protected branch. Audited as `repo.actions.logs.delete`. | | |
| 305 | +| `GET /api/v3/repos/{owner}/{repo}/actions/jobs/{job_id}` | One job, with its steps, outputs, failure and summary. | | |
| 306 | +| `GET /api/v3/repos/{owner}/{repo}/actions/jobs/{job_id}/logs` | The log as text (`<timestamp> <line>` per line). Add `format=json` for JSON lines, and `after=<seq>` to get only lines after a sequence number. | | |
| 307 | +| `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts` | The run's artifacts (every attempt when `run_id` is a run number), expired ones included. See [Download artifacts with the API](/docs/actions/caching-and-artifacts#download-artifacts-with-the-api) for the other artifact and cache endpoints. | | |
| 308 | +| `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/cancel` | Cancels the run (`202`). Needs write. | | |
| 309 | +| `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/rerun` | Re-runs every job of the run (`201`, `{"run_id": ...}`, the new attempt's ID). Needs write. | | |
| 310 | +| `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs` | Re-runs the run's failed jobs and the jobs that depend on them (`201`, `{"run_id": ...}`). See below. | | |
| 311 | +| `POST /api/v3/repos/{owner}/{repo}/actions/jobs/{job_id}/rerun` | Re-runs one job and the jobs that depend on it (`201`, `{"run_id": ...}`). See below. | | |
| 312 | + | |
| 313 | +Run objects use GitHub's field names (`id`, `node_id`, `run_number`, `run_attempt`, `name`, `display_title`, `path`, `workflow_id`, `event`, `head_branch`, `head_sha`, `sha`, `ref`, `status`, `conclusion`, `actor` and `triggering_actor` as user objects, `created_at`, `updated_at`, `run_started_at`, `url`, `html_url`, `jobs_url`, `logs_url`, `artifacts_url`, `cancel_url`, `rerun_url`, `workflow_url`, `previous_attempt_url` and `pull_requests`) plus `pull_request_number`. Job objects carry GitHub's fields (`run_id`, `run_attempt`, `head_sha`, `head_branch`, `html_url`, `started_at`, `completed_at`, `workflow_name`, `steps` with `number`) next to Diffinery's own camelCase fields, which the CLI reads. `head_sha` is the commit checks are reported on (the pull request head for pull request runs) and `sha` is the commit the run checked out. Run and job IDs are 32-character hexadecimal strings. Wherever a path takes `{run_id}`, the run number works too and means its latest attempt; this is what `github.run_id` and `GITHUB_RUN_ID` hold inside a job. A job's `check_run_url` points at `GET /repos/{owner}/{repo}/check-runs/{job_id}`, which returns the job's check run. While a job is running, one logs request returns at most 5,000 lines; page with `after`. The runs, jobs and artifacts lists send a `Link` header with `next`, `prev`, `first` and `last` URLs, so Octokit's `paginate()` works. Errors have a `message` and a `documentation_url`. Every endpoint is listed in the [API reference](/docs/api). | |
| 314 | + | |
| 315 | +### From MCP | |
| 316 | + | |
| 317 | +Agents can use `list_workflow_runs`, `get_workflow_run`, `get_job_logs` (the last 400 lines by default, set `tail` for more), `rerun_workflow` (with `failed_only` for the failed jobs only), `rerun_job`, `cancel_workflow` and `dispatch_workflow`. See [MCP tools](/docs/reference/mcp-tools). | |
| 318 | + | |
| 319 | +## Cancel a run | |
| 320 | + | |
| 321 | +Click **Cancel run** on the run page, run `diffinery run cancel <run-id>`, call `POST /actions/runs/{run_id}/cancel`, or use the `cancel_workflow` MCP tool. You need write access. | |
| 322 | + | |
| 323 | +Cancelling stops every unfinished job's container at once and marks those jobs `cancelled` with the reason `Cancelled by <login>`. Jobs that haven't started yet and have no status function in their `if:` are skipped; a job with `if: always()` or `if: cancelled()` still runs, as on GitHub. The run's conclusion is `cancelled`. | |
| 324 | + | |
| 325 | +## Re-run a run | |
| 326 | + | |
| 327 | +You can re-run a run once it has finished, in three ways. You need write access. | |
| 328 | + | |
| 329 | +| What runs again | Web | CLI | API | MCP | | |
| 330 | +| --- | --- | --- | --- | --- | | |
| 331 | +| Every job | **Re-run all jobs** on the run's page | `diffinery run rerun <run-id>` | `POST /actions/runs/{run_id}/rerun` | `rerun_workflow` | | |
| 332 | +| The failed jobs | **Re-run failed jobs** on the run's page (shown when a job failed, was cancelled or timed out) | `diffinery run rerun <run-id> --failed` | `POST /actions/runs/{run_id}/rerun-failed-jobs` | `rerun_workflow` with `failed_only: true` | | |
| 333 | +| One job | **Re-run this job** on the job's page | `diffinery run rerun --job <job-id>` | `POST /actions/jobs/{job_id}/rerun` | `rerun_job` | | |
| 334 | + | |
| 335 | +Each re-run is a new attempt of the run, with: | |
| 336 | + | |
| 337 | +- the same workflow file (read again from the original commit), commit, ref, event, event payload and inputs, | |
| 338 | +- the same run number and an attempt number one higher (shown as `attempt 2`, and available as `github.run_attempt`), so `github.run_id` stays the same, | |
| 339 | +- a new run ID in the API, and you as the actor, | |
| 340 | +- whether its code came from an agent, kept from the original run whoever re-runs it (see [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent)). | |
| 341 | + | |
| 342 | +**Re-run failed jobs** runs again, as on GitHub, every job and matrix leg whose conclusion was `failure`, `cancelled` or `timed_out`, and every job that depends on one of them through `needs`, directly or not (all of a dependent job's matrix legs run again). A job that never started because the run was cancelled first runs too. Jobs inside a [reusable workflow](/docs/actions/reusable-workflows) count like any other job, and a job that needs the calling job depends on every job inside the call. The other jobs and matrix legs are kept from the earlier attempt as they were: their conclusion, outputs, steps, log, summary and artifacts. They don't run again, start no runner and use no minutes, and they don't count toward `max-parallel`, so a matrix with `fail-fast` can't cancel a leg that already passed. Their check runs point at the new attempt with the same conclusion. On the run's page they're marked **Kept from an earlier attempt**, and in the API a kept job has `carriedFrom`, the ID of the job that ran. A run with no failed, cancelled or timed out job answers `409 this run has no failed jobs to re-run`. | |
| 343 | + | |
| 344 | +**Re-run this job** works the same way for one job (one matrix leg): it runs again with every job that depends on it, and every other job is kept, whatever its conclusion. | |
| 345 | + | |
| 346 | +The run's conclusion counts kept jobs like the others. The body field `enable_debug_logging` of the re-run endpoints is accepted and ignored. | |
| 347 | + | |
| 348 | +Failed-job and single-job re-runs need a finished run (`409 this run hasn't finished yet; ...` otherwise). If what failed was the workflow's set-up (the file couldn't be read or a reusable workflow couldn't be loaded), every job runs again. | |
| 349 | + | |
| 350 | +A workflow's own `GITHUB_TOKEN` can't re-run runs: `403 a workflow's GITHUB_TOKEN cannot re-run workflows`. | |
| 351 | + | |
| 352 | +It fails with `422 workflow file no longer valid at this commit` if the file can't be loaded from that commit any more. | |
| 353 | + | |
| 354 | +> **Note:** On GitHub a re-run keeps the same run ID in the API, and a kept job keeps its job ID and the attempt it ran in. On Diffinery the new attempt has a new `id` (the run number, `github.run_id`, stays the same), a kept job is a copy with its own ID in the new attempt, and its `run_attempt` is the new attempt's. | |
| 355 | + | |
| 356 | +## Job summaries | |
| 357 | + | |
| 358 | +Append Markdown to the file named by `$GITHUB_STEP_SUMMARY` in any step: | |
| 359 | + | |
| 360 | +```yaml | |
| 361 | +- name: Report coverage | |
| 362 | + run: | | |
| 363 | + { | |
| 364 | + echo "## Coverage" | |
| 365 | + echo "" | |
| 366 | + echo "| File | Lines |" | |
| 367 | + echo "| --- | --- |" | |
| 368 | + echo "| src/app.ts | 94% |" | |
| 369 | + } >> "$GITHUB_STEP_SUMMARY" | |
| 370 | +``` | |
| 371 | + | |
| 372 | +Each step gets its own empty summary file. At the end of the job, the non-empty summaries of all steps are joined in step order, secrets are masked, and the result is shown under **Job summary** on the job page and returned as `summary` by `GET /actions/jobs/{job_id}`. | |
| 373 | + | |
| 374 | +## Status checks on commits and pull requests | |
| 375 | + | |
| 376 | +Every job reports a check on the commit the run is about: | |
| 377 | + | |
| 378 | +- For `push`, `merge_group`, `workflow_dispatch`, `schedule`, `workflow_run`, `release` and `repository_dispatch` runs, that is the run's commit. | |
| 379 | +- For `pull_request` and `pull_request_target` runs, it is the pull request's head commit, even though the job checks out the test merge commit or the base commit. | |
| 380 | + | |
| 381 | +The check is named `<workflow name> / <job name>`, for example `CI / test`, or `CI / test (ubuntu-latest, 22)` for a matrix job without a `name`. It becomes `queued` when the job is launched, `in_progress` when a runner picks it up, and `completed` with the job's conclusion. Its details link opens the job's log. | |
| 382 | + | |
| 383 | +Pull requests show the checks of their head commit in the merge box and on the **Checks** tab. To block merging until checks pass, list them by these exact names under **Required status checks** in a branch protection rule; see [Branch protection](/docs/repositories/branch-protection). | |
| 384 | + | |
| 385 | +External CI systems can report results too, as check runs (`POST /repos/{owner}/{repo}/check-runs`) or as commit statuses (`POST /repos/{owner}/{repo}/statuses/{sha}`, where the status's `context` is its name). Both count as checks for required checks and the merge queue. Read them with `GET /repos/{owner}/{repo}/commits/{ref}/check-runs` and `GET /repos/{owner}/{repo}/commits/{ref}/status`. See [Checks and commit statuses](/docs/collaboration/checks-and-statuses). | |
| 386 | + | |
| 387 | +How Diffinery decides a required check when several reporters use the same name: | |
| 388 | + | |
| 389 | +- When Diffinery Actions reports a check with that name, only its result counts. Another reporter can't satisfy or override it. | |
| 390 | +- To have a required check decided by a specific reporter instead, bind it to that reporter in the branch protection rule through the API (`requiredCheckSources`, for example `{"ci/external": "ci-bot"}`, or `diffinery-actions` for Actions). | |
| 391 | +- Results that agents report never satisfy a required check. | |
| 392 | +- Of the reports that count, any failure fails the check, and any unfinished one keeps it pending. | |
| 393 | +- Check runs created by Diffinery Actions can't be changed through the REST API. | |
| 394 | + | |
| 395 | +> **Important:** A job that is skipped by its `if:` condition, or that fails before it is launched (for example because no runner matches its `runs-on`), doesn't report a check. If such a job is a required check, the pull request shows `required check '<name>' has not run`. A check also appears only when its job is launched, so a required check for a job that `needs` others shows as not run until those finish. | |
| 396 | + | |
| 397 | +## Concurrency | |
| 398 | + | |
| 399 | +Set `concurrency` at the top of a workflow to make runs in the same group exclusive: | |
| 400 | + | |
| 401 | +```yaml | |
| 402 | +concurrency: | |
| 403 | + group: deploy-${{ github.ref }} | |
| 404 | + cancel-in-progress: true | |
| 405 | +``` | |
| 406 | + | |
| 407 | +- The group is evaluated when the run is created, with the `github`, `inputs` and `vars` contexts. Groups are scoped to the repository, so two workflows can share a group. | |
| 408 | +- With `cancel-in-progress: true`, creating a run cancels every unfinished run (queued or in progress) in the same group, with the reason `cancelled by a newer run in the same concurrency group`. | |
| 409 | +- Without it, a new run waits while another run in the group is in progress, checking once a minute. After 6 hours of waiting it starts anyway. As on GitHub, a group has at most one waiting run: a new run cancels older runs still waiting in the group (not the one in progress), with the reason `Canceling since a higher priority waiting request for '<group>' exists`, so a deploy group runs the newest push next, never older ones one after another. A run cancelled while it waits finishes within a minute, with none of its jobs run. | |
| 410 | +- A run or job on a protected branch is cancelled or replaced through its group only by runs on that same branch. A workflow on another branch that names the same group, `cancel-in-progress` or not, waits for it instead, so a branch can't cancel a protected branch's deploy. | |
| 411 | + | |
| 412 | +If the group expression fails to evaluate, the run has no concurrency group. | |
| 413 | + | |
| 414 | +### Job concurrency | |
| 415 | + | |
| 416 | +`jobs.<id>.concurrency` limits a group to one running job at a time across the repository, whatever run or workflow the jobs belong to: | |
| 417 | + | |
| 418 | +```yaml | |
| 419 | +jobs: | |
| 420 | + deploy: | |
| 421 | + runs-on: ubuntu-latest | |
| 422 | + concurrency: | |
| 423 | + group: deploy-${{ matrix.env }} | |
| 424 | + cancel-in-progress: false | |
| 425 | + strategy: | |
| 426 | + matrix: | |
| 427 | + env: [staging, production] | |
| 428 | + steps: | |
| 429 | + - run: ./deploy.sh ${{ matrix.env }} | |
| 430 | +``` | |
| 431 | + | |
| 432 | +- The group is evaluated for each matrix combination, with the `github`, `needs`, `inputs`, `vars`, `matrix` and `strategy` contexts. | |
| 433 | +- Without `cancel-in-progress`, a job whose group has a running job waits with the status `pending`. Only the newest pending job is kept: an older pending job of the group is cancelled. | |
| 434 | +- With `cancel-in-progress: true`, the new job takes the group and cancels the group's running and pending jobs. | |
| 435 | +- Jobs cancelled this way end as `cancelled` with the message `Canceling since a higher priority waiting request for '<group>' exists`. | |
| 436 | +- When the running job finishes, the oldest pending job of the group starts, from whichever run it belongs to. | |
| 437 | + | |
| 438 | +## Notifications | |
| 439 | + | |
| 440 | +When a run completes with `success` or `failure`, Diffinery notifies the person who triggered it (for a run an agent triggered, the agent's owner; for runs started by the scheduler or the merge queue, the repository's watchers). By default they get an email and a web notification when a run fails; in their notification settings they can turn off **Only failed runs** to get emails for successful runs too. Cancelled runs don't notify anyone. See [Notifications](/docs/accounts/notifications). | |
| 441 | + | |
| 442 | +## Troubleshooting | |
| 443 | + | |
| 444 | +| Symptom | Cause and fix | | |
| 445 | +| --- | --- | | |
| 446 | +| Nothing runs after a push | Check that the file is directly in `.github/workflows/` or `.diffinery/workflows/` and ends in `.yml` or `.yaml`, and that the `on:` filters match the branch, tag or paths. Run `diffinery workflow validate`. | | |
| 447 | +| A run stays in progress though all its jobs finished, and newer runs in its concurrency group wait with no jobs | The run's orchestration lost track of it. Diffinery repairs it by itself: within 5 minutes of its last job the orchestration is signalled again, and by 30 minutes the run is finished, freeing the group. Nothing to do; re-run the workflow if it names jobs that never started. | | |
| 448 | +| A run titled `Invalid workflow file: ...` appears | A workflow file in the pushed commit has errors. Its one job, **Invalid workflow file**, lists every error with its path and line. Pushes and merge groups create these runs; other events skip invalid files silently. | | |
| 449 | +| A pull request's `pull_request` workflows don't run | The pull request conflicts with its base, so there is no test merge commit. Resolve the conflicts. `pull_request_target` workflows still run. | | |
| 450 | +| `No Diffinery runner matches runs-on: <labels>. Linux (ubuntu-*) runners are hosted; anything else needs runs-on: [self-hosted, ...] and a registered runner.` | Hosted runners accept `ubuntu-*`, `linux`, `diffinery`, `diffinery-*`, `x64` and `amd64`. For other platforms, add `self-hosted` and start a runner with matching labels. See [Runners](/docs/actions/runners). | | |
| 451 | +| A self-hosted job stays `waiting` | No runner with all the job's labels is polling. Start `diffinery runner` with those labels. After 6 hours the job times out. | | |
| 452 | +| `The job was not started: this account has used its <n> hosted minutes for this month ...` | The account that owns the repository used its monthly hosted minutes. They renew on the 1st (UTC); until then use a self-hosted runner, or ask a site administrator to raise the quota. See [Account quotas](/docs/reference/limits#account-quotas). | | |
| 453 | +| A hosted job stays queued, then fails with `The job waited 6 hours for a hosted runner (one of this account's hosted jobs to finish, or a free runner), and was not started.` | The account already runs as many hosted jobs at once as its quota allows (20 by default, at most 40), or every hosted runner of the instance was busy. Queued jobs start as running ones finish. See [Account quotas](/docs/reference/limits#account-quotas) and [hosted runner limits](/docs/reference/limits#actions). | | |
| 454 | +| `The job was not started: Actions are suspended for this repository by a site administrator: <reason>.` (or `for every repository of <account>`) | A site administrator suspended Actions here. Nothing runs until they lift it, and a red banner on the Actions page shows the reason. See [Kill switches](/docs/admin/site-administration#kill-switches). | | |
| 455 | +| `The job was not started: Hosted runners are paused on this instance by a site administrator: <reason>. Self-hosted runners still work.` | Hosted compute is paused on the whole instance. Use a self-hosted runner, or re-run once it is resumed. See [Kill switches](/docs/admin/site-administration#kill-switches). | | |
| 456 | +| `Could not start a runner: ...` | The job's container failed to start with an error that trying again won't fix (passing platform errors are retried first; see [Limits](/docs/reference/limits)). Re-run the run. | | |
| 457 | +| `The runner stopped unexpectedly (exit code <n>).` | The job's container exited before reporting a result. Re-run the run; if it repeats, check that the job doesn't exhaust memory or disk. | | |
| 458 | +| `The platform is stopping this runner (a rollout replacing it with a new version, or the platform moving it). The job keeps running for up to 13 minutes; ...` | The platform sent the job's container its stop signal. Deploys leave running jobs alone, so this is rare and usually the platform moving the container. The signal reaches the running step as well, which then fails with "The platform stopped this step's processes while stopping the runner." A job that can only read starts again by itself; see Platform updates in [Runners](/docs/actions/runners#limits-and-timeouts). Otherwise re-run the job. | | |
| 459 | +| `Error evaluating 'if': ...` | The job's `if:` expression failed at runtime. See [Expressions](/docs/actions/expressions). | | |
| 460 | +| A step can't reach a secret | Secrets are configured in the repository or organization settings. See [Secrets and variables](/docs/actions/secrets-and-variables). | | |
| 461 | +| `git push` from a job fails | The job's `GITHUB_TOKEN` needs `contents: write` (new repositories default to read-only tokens), can't change workflow files, and follows branch protection. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions). | | |
| 462 | +| `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: ...)` | Add the named scope to the job's `permissions:`. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions). | | |
| 463 | +| A job is `skipped` though its `if:` is true | A job above it in the `needs` chain failed, was cancelled or was skipped. Add a status function, for example `if: ${{ !cancelled() && !failure() }}`. | | |
| 464 | +| `<branch> is not allowed to deploy to <environment> due to environment protection rules.` | The job sets `environment:` and the environment's branch policy doesn't allow the run's ref. Run it from an allowed branch or tag, or change the policy. See [Environments](/docs/actions/environments). | | |
| 465 | +| `invalid environment name '<name>'` | The job's `environment` name is empty, longer than 255 characters, or contains `/`, `\` or control characters. | | |
| 466 | +| An `on: release` workflow runs three times | Creating a published release sends `created`, `published` and `released`. Add `types: [published]`. See [release](/docs/actions/events#release). | | |
| 467 | +| An `on: release` workflow doesn't run | Workflows don't run for draft releases, and the workflow file must exist at the commit the release's tag points to. | |
addedapps/diffinery/docs/actions/reusable-workflows.md+424−0View file
| @@ -1,0 +1,424 @@ | ||
| 1 | +--- | |
| 2 | +title: Reusable workflows | |
| 3 | +description: Call one workflow from another with jobs.<id>.uses, pass inputs and secrets, read outputs, and share workflows across repositories. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +A reusable workflow is a workflow file with an `on: workflow_call` trigger that other workflows call as a job, with `jobs.<id>.uses`. Use one to keep a build, test or deploy pipeline in one place and call it from many workflows or repositories. Diffinery implements GitHub's syntax and behaviour; the differences are listed in [Differences from GitHub](#differences-from-github). | |
| 7 | + | |
| 8 | +## A minimal example | |
| 9 | + | |
| 10 | +The called workflow declares its inputs, secrets and outputs under `on.workflow_call`: | |
| 11 | + | |
| 12 | +```yaml | |
| 13 | +# .github/workflows/build.yml | |
| 14 | +name: Build | |
| 15 | +on: | |
| 16 | + workflow_call: | |
| 17 | + inputs: | |
| 18 | + node-version: | |
| 19 | + type: string | |
| 20 | + default: "22" | |
| 21 | + run-e2e: | |
| 22 | + type: boolean | |
| 23 | + default: false | |
| 24 | + secrets: | |
| 25 | + NPM_TOKEN: | |
| 26 | + required: true | |
| 27 | + outputs: | |
| 28 | + version: | |
| 29 | + description: The version that was built | |
| 30 | + value: ${{ jobs.build.outputs.version }} | |
| 31 | + | |
| 32 | +jobs: | |
| 33 | + build: | |
| 34 | + runs-on: ubuntu-latest | |
| 35 | + outputs: | |
| 36 | + version: ${{ steps.meta.outputs.version }} | |
| 37 | + steps: | |
| 38 | + - uses: actions/checkout@v4 | |
| 39 | + - uses: actions/setup-node@v4 | |
| 40 | + with: | |
| 41 | + node-version: ${{ inputs.node-version }} | |
| 42 | + - run: npm ci | |
| 43 | + env: | |
| 44 | + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| 45 | + - id: meta | |
| 46 | + run: echo "version=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT" | |
| 47 | + - if: ${{ inputs.run-e2e }} | |
| 48 | + run: npm run test:e2e | |
| 49 | +``` | |
| 50 | + | |
| 51 | +The caller uses it as a job: | |
| 52 | + | |
| 53 | +```yaml | |
| 54 | +# .github/workflows/ci.yml | |
| 55 | +name: CI | |
| 56 | +on: push | |
| 57 | + | |
| 58 | +jobs: | |
| 59 | + build: | |
| 60 | + uses: ./.github/workflows/build.yml | |
| 61 | + with: | |
| 62 | + run-e2e: true | |
| 63 | + secrets: | |
| 64 | + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| 65 | + | |
| 66 | + report: | |
| 67 | + needs: build | |
| 68 | + runs-on: ubuntu-latest | |
| 69 | + steps: | |
| 70 | + - run: echo "Built version ${{ needs.build.outputs.version }}" | |
| 71 | +``` | |
| 72 | + | |
| 73 | +## Calling a workflow | |
| 74 | + | |
| 75 | +A calling job has `uses` instead of `runs-on` and `steps`. It accepts these keys: | |
| 76 | + | |
| 77 | +| Key | Description | | |
| 78 | +|---|---| | |
| 79 | +| `uses` | The workflow to call (required). See the formats below. | | |
| 80 | +| `with` | Values for the called workflow's inputs. | | |
| 81 | +| `secrets` | A mapping of secrets, or `inherit`. | | |
| 82 | +| `needs` | Jobs to wait for, as for any job. | | |
| 83 | +| `if` | Skip the call (and every job inside it) when false. | | |
| 84 | +| `name` | Display name, used as the prefix of the jobs inside the call. | | |
| 85 | +| `strategy` | Accepted, but a `matrix` isn't supported yet (see [Matrix on the calling job](#matrix-on-the-calling-job)). | | |
| 86 | +| `permissions` | The most the jobs inside the call may have. They inherit these permissions, and anything the called workflow declares is lowered to them. See [Permissions in called workflows](#permissions-in-called-workflows). | | |
| 87 | +| `concurrency` | Accepted but has no effect on a calling job. Jobs inside the called workflow can use their own job-level `concurrency`. | | |
| 88 | + | |
| 89 | +Any other key produces a warning (`unknown reusable-workflow job key '...'`) and is ignored. That includes `environment`: as on GitHub, a calling job can't use an environment, so put `environment:` on the jobs inside the called workflow (see [Environments in called workflows](#environments-in-called-workflows)). `steps` or `runs-on` on a calling job is an error. | |
| 90 | + | |
| 91 | +### Local workflows | |
| 92 | + | |
| 93 | +```yaml | |
| 94 | +uses: ./.github/workflows/build.yml | |
| 95 | +``` | |
| 96 | + | |
| 97 | +The path starts with `./`, is relative to the repository root, names a file in `.github/workflows/` or `.diffinery/workflows/` and ends in `.yml` or `.yaml`. The file is read from the **same commit** as the calling workflow, so a branch or pull request can change both together. Workflows under `.diffinery/workflows/` can be called the same way (`./.diffinery/workflows/build.yml`). | |
| 98 | + | |
| 99 | +### Workflows in other repositories | |
| 100 | + | |
| 101 | +```yaml | |
| 102 | +uses: acme/shared-workflows/.github/workflows/deploy.yml@v2 | |
| 103 | +``` | |
| 104 | + | |
| 105 | +The format is `<owner>/<repo>/<path>@<ref>`, where `<ref>` is a branch, a tag or a commit SHA, and the repository is on the same Diffinery instance (here, diffinery.com). A workflow on github.com can't be called; copy it into a Diffinery repository first. | |
| 106 | + | |
| 107 | +Which repositories you can call: | |
| 108 | + | |
| 109 | +| Called repository | Can be called from | | |
| 110 | +|---|---| | |
| 111 | +| Public | Any repository on Diffinery | | |
| 112 | +| Private | Only repositories with the **same owner** (the same user or organization) | | |
| 113 | + | |
| 114 | +Access is decided by the two repositories' owners, not by who pushed the commit. A call that isn't allowed fails with `repository acme/shared-workflows not found, or its workflows are not shared with acme/website`. | |
| 115 | + | |
| 116 | +Inside a workflow called from another repository, a local `./` reference resolves in that other repository, at the commit the called workflow came from. | |
| 117 | + | |
| 118 | +A branch or tag `<ref>` is resolved once, when the run is planned: every job of the run, including one that waits for an environment's approval, uses the called workflow from that commit, even if the branch moves during the run. | |
| 119 | + | |
| 120 | +## Inputs | |
| 121 | + | |
| 122 | +Declare inputs under `on.workflow_call.inputs`. Each input must have a `type`: | |
| 123 | + | |
| 124 | +| Key | Description | | |
| 125 | +|---|---| | |
| 126 | +| `type` | `string`, `boolean` or `number` (required). | | |
| 127 | +| `required` | `true` to make the call fail when the input is missing or empty. | | |
| 128 | +| `default` | Value used when the caller doesn't pass one. Expressions are allowed. | | |
| 129 | +| `description` | Free text. | | |
| 130 | + | |
| 131 | +The caller passes values with `with`. Rules: | |
| 132 | + | |
| 133 | +- Every key in `with` must be a declared input. An unknown key fails the run with `jobs.<id>.with.<key>: <uses> has no input named '<key>'`. | |
| 134 | +- `with` values can use expressions with the `github`, `needs` (the calling job's `needs`), `inputs` (when the caller is itself inside a reusable workflow) and `vars` contexts. | |
| 135 | +- Values are converted to the declared type: a `boolean` input is true for `true` or `"true"`; a `number` input goes through `Number()`. | |
| 136 | +- A missing optional input with no default is `""`, `false` or `0` depending on its type. | |
| 137 | +- A missing required input fails the jobs inside the call with `jobs.<id>: input '<name>' is required by <uses>`. | |
| 138 | + | |
| 139 | +Inside the called workflow, read inputs with `${{ inputs.<name> }}`. | |
| 140 | + | |
| 141 | +## Secrets | |
| 142 | + | |
| 143 | +The called workflow only sees the secrets the caller passes, plus `GITHUB_TOKEN`, which is always the job's own token, plus, in a job with `environment:`, that environment's secrets (see [Environments in called workflows](#environments-in-called-workflows)). | |
| 144 | + | |
| 145 | +Pass specific secrets with a mapping. Each value is an expression evaluated against the caller's secrets: | |
| 146 | + | |
| 147 | +```yaml | |
| 148 | +secrets: | |
| 149 | + DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }} | |
| 150 | + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| 151 | +``` | |
| 152 | + | |
| 153 | +Or pass everything the caller has with `inherit`: | |
| 154 | + | |
| 155 | +```yaml | |
| 156 | +secrets: inherit | |
| 157 | +``` | |
| 158 | + | |
| 159 | +At the top level, "everything the caller has" is the calling repository's secrets plus its organization's secrets. In a nested call, it's whatever the enclosing call received. | |
| 160 | + | |
| 161 | +Declare the secrets a workflow needs under `on.workflow_call.secrets`. A secret declared with `required: true` that the caller doesn't provide fails the jobs inside the call with `jobs.<id>: secret '<NAME>' is required by <uses>`. Secret names are case-insensitive and are stored uppercase. | |
| 162 | + | |
| 163 | +Secrets for a cross-repository call always come from the **calling** repository and its organization, never from the repository that holds the workflow. | |
| 164 | + | |
| 165 | +## Outputs | |
| 166 | + | |
| 167 | +A called workflow exposes results through `on.workflow_call.outputs`. Each output has a `value`, an expression that can read the `jobs` context of the called workflow (`jobs.<job id>.outputs.<name>` and `jobs.<job id>.result`) and its `inputs`: | |
| 168 | + | |
| 169 | +```yaml | |
| 170 | +on: | |
| 171 | + workflow_call: | |
| 172 | + outputs: | |
| 173 | + image: | |
| 174 | + value: ${{ jobs.publish.outputs.image }} | |
| 175 | +``` | |
| 176 | + | |
| 177 | +The caller reads it as `${{ needs.<calling job id>.outputs.image }}`. Values are strings; objects are JSON-encoded. Only declared outputs are visible to the caller. | |
| 178 | + | |
| 179 | +`needs.<calling job id>.result` summarizes the jobs inside the call: `failure` if any failed or timed out, otherwise `cancelled` if any was cancelled, `skipped` if all were skipped, and `success` otherwise. | |
| 180 | + | |
| 181 | +## Contexts inside a called workflow | |
| 182 | + | |
| 183 | +| Context | Value inside the called workflow | | |
| 184 | +|---|---| | |
| 185 | +| `github` | The caller's run: the same event, `ref`, `sha`, `repository` and actor. `github.job` is the job's own key inside the called workflow. | | |
| 186 | +| `inputs` | The resolved inputs. | | |
| 187 | +| `secrets` | The passed secrets and `GITHUB_TOKEN`, plus the environment's secrets in a job with `environment:`. | | |
| 188 | +| `vars` | The calling repository's variables, plus the environment's variables in a job with `environment:`. | | |
| 189 | +| `env` | The called workflow's own `env` (workflow and job level). The caller's `env` is not passed. | | |
| 190 | +| `needs` | Job ids as written inside the called workflow. | | |
| 191 | + | |
| 192 | +Because `github.repository` is the caller's, `actions/checkout` with no inputs checks out the **calling** repository, as on GitHub. To check out the repository that holds the workflow, pass `repository:`. The job's `GITHUB_TOKEN` can read that repository when it's public; when it's private, also pass a `token:` from a secret. | |
| 193 | + | |
| 194 | +### Permissions in called workflows | |
| 195 | + | |
| 196 | +The jobs inside a call get their `GITHUB_TOKEN` permissions like this, as on GitHub: | |
| 197 | + | |
| 198 | +1. Start from the calling job's effective permissions: its own `permissions:`, or else the calling workflow's, or else the repository default. | |
| 199 | +2. If the called workflow or the called job declares `permissions:`, use that instead, but lowered scope by scope to what the calling job has. A called workflow can never get more than its caller. | |
| 200 | +3. If neither declares anything, the called jobs get exactly the calling job's permissions. | |
| 201 | + | |
| 202 | +```yaml | |
| 203 | +# .github/workflows/release.yml in acme/website | |
| 204 | +jobs: | |
| 205 | + deploy: | |
| 206 | + permissions: | |
| 207 | + contents: read | |
| 208 | + id-token: write | |
| 209 | + uses: ./.github/workflows/deploy.yml | |
| 210 | +``` | |
| 211 | + | |
| 212 | +Here the jobs in `deploy.yml` can have at most `contents: read` and `id-token: write`. If `deploy.yml` declares `permissions: write-all`, its jobs still get only those two. If it declares `packages: write`, its jobs get no `packages` access, because the calling job has none. This applies to `id-token` for [OIDC](/docs/actions/oidc), to `packages` for the [container registry](/docs/packages/container-registry), and to every other scope. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions#reusable-workflows). | |
| 213 | + | |
| 214 | +## Environments in called workflows | |
| 215 | + | |
| 216 | +A job inside a called workflow can deploy to an [environment](/docs/actions/environments) with `environment:`, and the environment's name and `url` can use the called workflow's `inputs`: | |
| 217 | + | |
| 218 | +```yaml | |
| 219 | +jobs: | |
| 220 | + deploy: | |
| 221 | + runs-on: ubuntu-latest | |
| 222 | + environment: | |
| 223 | + name: ${{ inputs.environment }} | |
| 224 | + url: https://${{ inputs.environment }}.example.com | |
| 225 | + steps: | |
| 226 | + - run: ./deploy.sh | |
| 227 | +``` | |
| 228 | + | |
| 229 | +How it works: | |
| 230 | + | |
| 231 | +- The environment belongs to the **calling** repository, the one whose run it is, even when the workflow file lives in another repository. Its branch policy is checked against the caller's ref. | |
| 232 | +- The job gets the environment's secrets and variables without the caller passing them in `secrets:`, as on GitHub. An environment secret replaces a passed secret with the same name. | |
| 233 | +- A secret declared with `required: true` under `on.workflow_call.secrets` must still be passed by the caller, even if the environment has it. Don't declare secrets that come from the environment. | |
| 234 | +- The job records a deployment like any other job with `environment:`, and the usual rules for who receives environment secrets apply (pull request refs never do; runs whose code came from an agent only from environments set to protected branches only). | |
| 235 | + | |
| 236 | +## How calls appear in runs and checks | |
| 237 | + | |
| 238 | +The called workflow's jobs become jobs of the caller's run. A calling job `build` that calls a workflow with jobs `compile` and `test` produces the jobs **build / compile** and **build / test** (the calling job's `name`, if it has one, replaces `build`). Checks on the commit are named `<workflow name> / <calling job> / <job>`. | |
| 239 | + | |
| 240 | +Ordering follows GitHub: | |
| 241 | + | |
| 242 | +- Jobs inside the call that have no `needs` wait for the calling job's `needs`. | |
| 243 | +- A job that `needs` the calling job waits for every job inside the call. | |
| 244 | +- If the calling job's `if` is false, or (with the default `if`) any job it depends on, directly or through other jobs, failed or was skipped, every job inside the call is reported as skipped. | |
| 245 | + | |
| 246 | +## Nesting | |
| 247 | + | |
| 248 | +A called workflow can itself call reusable workflows, up to **four levels** below the top-level workflow, as on GitHub. A fifth level fails the run with: | |
| 249 | + | |
| 250 | +```text | |
| 251 | +jobs.a/b/c/d/e: reusable workflows can be nested at most 4 levels deep | |
| 252 | +``` | |
| 253 | + | |
| 254 | +A run can call at most **20** reusable workflows in total (counting every call at every level); more fails with `a run can call at most 20 reusable workflows`. | |
| 255 | + | |
| 256 | +## Matrix on the calling job | |
| 257 | + | |
| 258 | +A `strategy.matrix` on a job that calls a reusable workflow isn't supported yet. The run fails with: | |
| 259 | + | |
| 260 | +```text | |
| 261 | +jobs.deploy: a matrix on a job that calls a reusable workflow is not supported yet | |
| 262 | +``` | |
| 263 | + | |
| 264 | +Put the matrix on the jobs inside the called workflow instead, passing the list as an input: | |
| 265 | + | |
| 266 | +```yaml | |
| 267 | +# caller | |
| 268 | +jobs: | |
| 269 | + test: | |
| 270 | + uses: ./.github/workflows/test.yml | |
| 271 | + with: | |
| 272 | + versions: '["20", "22"]' | |
| 273 | + | |
| 274 | +# .github/workflows/test.yml | |
| 275 | +on: | |
| 276 | + workflow_call: | |
| 277 | + inputs: | |
| 278 | + versions: | |
| 279 | + type: string | |
| 280 | + required: true | |
| 281 | +jobs: | |
| 282 | + test: | |
| 283 | + runs-on: ubuntu-latest | |
| 284 | + strategy: | |
| 285 | + matrix: | |
| 286 | + node: ${{ fromJSON(inputs.versions) }} | |
| 287 | + steps: | |
| 288 | + - uses: actions/setup-node@v4 | |
| 289 | + with: | |
| 290 | + node-version: ${{ matrix.node }} | |
| 291 | + - run: node --version | |
| 292 | +``` | |
| 293 | + | |
| 294 | +Or write one calling job per value. | |
| 295 | + | |
| 296 | +## Example: a shared deploy pipeline | |
| 297 | + | |
| 298 | +An organization keeps its deploy workflow in `acme/shared-workflows` (private) and calls it from `acme/website` (same owner, so allowed): | |
| 299 | + | |
| 300 | +```yaml | |
| 301 | +# acme/shared-workflows: .github/workflows/deploy.yml | |
| 302 | +name: Deploy | |
| 303 | +on: | |
| 304 | + workflow_call: | |
| 305 | + inputs: | |
| 306 | + environment: | |
| 307 | + type: string | |
| 308 | + required: true | |
| 309 | + dry-run: | |
| 310 | + type: boolean | |
| 311 | + default: false | |
| 312 | + secrets: | |
| 313 | + CLOUDFLARE_API_TOKEN: | |
| 314 | + required: true | |
| 315 | + outputs: | |
| 316 | + url: | |
| 317 | + value: ${{ jobs.deploy.outputs.url }} | |
| 318 | + | |
| 319 | +jobs: | |
| 320 | + deploy: | |
| 321 | + runs-on: ubuntu-latest | |
| 322 | + environment: ${{ inputs.environment }} # acme/website's staging or production environment | |
| 323 | + outputs: | |
| 324 | + url: ${{ steps.deploy.outputs.url }} | |
| 325 | + steps: | |
| 326 | + - uses: actions/checkout@v4 # checks out acme/website, the caller | |
| 327 | + - run: npm ci && npm run build | |
| 328 | + - id: deploy | |
| 329 | + env: | |
| 330 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 331 | + run: | | |
| 332 | + if [ "${{ inputs.dry-run }}" = "true" ]; then | |
| 333 | + npx wrangler deploy --dry-run | |
| 334 | + else | |
| 335 | + npx wrangler deploy --env "${{ inputs.environment }}" | |
| 336 | + fi | |
| 337 | + echo "url=https://${{ inputs.environment }}.example.com" >> "$GITHUB_OUTPUT" | |
| 338 | +``` | |
| 339 | + | |
| 340 | +```yaml | |
| 341 | +# acme/website: .github/workflows/release.yml | |
| 342 | +name: Release | |
| 343 | +on: | |
| 344 | + push: | |
| 345 | + branches: [main] | |
| 346 | + | |
| 347 | +jobs: | |
| 348 | + test: | |
| 349 | + runs-on: ubuntu-latest | |
| 350 | + steps: | |
| 351 | + - uses: actions/checkout@v4 | |
| 352 | + - run: npm ci && npm test | |
| 353 | + | |
| 354 | + staging: | |
| 355 | + needs: test | |
| 356 | + uses: acme/shared-workflows/.github/workflows/deploy.yml@v2 | |
| 357 | + with: | |
| 358 | + environment: staging | |
| 359 | + secrets: inherit | |
| 360 | + | |
| 361 | + production: | |
| 362 | + needs: staging | |
| 363 | + uses: acme/shared-workflows/.github/workflows/deploy.yml@v2 | |
| 364 | + with: | |
| 365 | + environment: production | |
| 366 | + secrets: | |
| 367 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 368 | + | |
| 369 | + announce: | |
| 370 | + needs: production | |
| 371 | + runs-on: ubuntu-latest | |
| 372 | + steps: | |
| 373 | + - run: echo "Live at ${{ needs.production.outputs.url }}" | |
| 374 | +``` | |
| 375 | + | |
| 376 | +The `deploy` job uses `acme/website`'s `staging` and `production` environments, so each call is checked against that environment's branch policy and records a deployment there. If those environments have their own `CLOUDFLARE_API_TOKEN` secret, it replaces the one the caller passes. | |
| 377 | + | |
| 378 | +## Differences from GitHub | |
| 379 | + | |
| 380 | +| Area | Diffinery | | |
| 381 | +|---|---| | |
| 382 | +| Where workflows come from | Only repositories on the same Diffinery instance. | | |
| 383 | +| Private repositories | Callable only from repositories with the same owner. | | |
| 384 | +| Matrix on the calling job | Not supported yet. | | |
| 385 | +| `concurrency` | The top-level workflow's `concurrency` and job-level `concurrency` on the jobs inside a call apply. A called workflow's top-level `concurrency` and a calling job's `concurrency` are ignored. | | |
| 386 | +| Number of calls per run | At most 20. | | |
| 387 | +| OIDC `job_workflow_ref` claim | The top-level workflow's reference, not the called workflow's. See [OpenID Connect (OIDC)](/docs/actions/oidc). | | |
| 388 | +| Branch references | Re-resolved whenever a job of the run is planned or started. | | |
| 389 | + | |
| 390 | +## Errors | |
| 391 | + | |
| 392 | +Errors in the **calling workflow's syntax** make the workflow file invalid. On `push`, Diffinery records a failed run titled "Invalid workflow file: ..."; the workflow list in the API reports it as `invalid`. | |
| 393 | + | |
| 394 | +| Message | Fix | | |
| 395 | +|---|---| | |
| 396 | +| `reusable workflow reference '...' must be './path/to/workflow.yml' or 'owner/repo/path/to/workflow.yml@ref'` | Fix the `uses` value. Cross-repository references need `@<ref>`; local ones need `./`. | | |
| 397 | +| `'./...' must be a file in ./.github/workflows or ./.diffinery/workflows` | Move the called workflow into a workflow directory. Files there count as workflow changes, which agents can't merge on their own and job tokens can't push. | | |
| 398 | +| `a job that calls a reusable workflow ('uses') cannot have 'steps'` (or `'runs-on'`) | Move the steps into the called workflow. | | |
| 399 | +| `input '<name>' must declare a 'type'` | Add `type: string`, `boolean` or `number` to the input under `on.workflow_call.inputs`. | | |
| 400 | +| `input '<name>': invalid type '<t>' (expected one of string, boolean, number)` | `choice` and `environment` exist only for `workflow_dispatch`. | | |
| 401 | +| `workflow_call output '<name>' must be a mapping with a 'value'` | Give each output a `value:` expression. | | |
| 402 | + | |
| 403 | +Errors found while **loading the called workflows** fail the run before any job starts. The run shows one failed job, **Set up workflow**, with the message: | |
| 404 | + | |
| 405 | +| Message | Fix | | |
| 406 | +|---|---| | |
| 407 | +| `jobs.<id>.uses: <uses> not found` | The file doesn't exist at that path and ref. | | |
| 408 | +| `jobs.<id>.uses: unknown ref <ref> in <owner>/<repo>` | The branch, tag or SHA doesn't exist in the called repository. | | |
| 409 | +| `jobs.<id>.uses: repository <owner>/<repo> not found, or its workflows are not shared with <owner>/<repo>` | The repository doesn't exist, or it's private and has a different owner. | | |
| 410 | +| `jobs.<id>.uses: <uses> is not a reusable workflow (it has no 'on: workflow_call' trigger)` | Add `workflow_call` to the called workflow's `on`. | | |
| 411 | +| `jobs.<id>.uses: <uses> is invalid: ...` | The called workflow has a syntax error; the details follow. | | |
| 412 | +| `jobs.<id>.with.<key>: <uses> has no input named '<key>'` | Remove the key from `with`, or declare the input. | | |
| 413 | +| `jobs.<id>: a matrix on a job that calls a reusable workflow is not supported yet` | Move the matrix into the called workflow. | | |
| 414 | +| `jobs.<id>: reusable workflows can be nested at most 4 levels deep` | Flatten the chain of calls. | | |
| 415 | +| `a run can call at most 20 reusable workflows` | Combine calls. | | |
| 416 | + | |
| 417 | +Errors in the **values passed** fail the jobs inside the call when they're about to start: | |
| 418 | + | |
| 419 | +| Message | Fix | | |
| 420 | +|---|---| | |
| 421 | +| `jobs.<id>: input '<name>' is required by <uses>` | Pass the input with `with`, or give it a default. | | |
| 422 | +| `jobs.<id>: secret '<NAME>' is required by <uses>` | Pass the secret in `secrets`, use `secrets: inherit`, or create the secret in the calling repository or its organization. | | |
| 423 | + | |
| 424 | +For the full workflow syntax, see [Workflow syntax](/docs/actions/workflow-syntax). For expressions, see [Expressions and contexts](/docs/actions/expressions). |
addedapps/diffinery/docs/actions/runners.md+447−0View file
| @@ -1,0 +1,447 @@ | ||
| 1 | +--- | |
| 2 | +title: Runners | |
| 3 | +description: Where workflow jobs run: hosted Linux runners in Cloudflare Containers (sizes, limits, late starts, restarts after a runner is replaced) and self-hosted runners via diffinery runner. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +A runner is the machine that executes one workflow job. Diffinery offers two kinds: **hosted runners**, which run each job in a fresh Ubuntu 24.04 container on Cloudflare, and **self-hosted runners**, which run jobs on a machine you control (a Mac, a GPU box, a server on a private network) using the `diffinery runner` command. You pick one per job with `runs-on`, the same key GitHub Actions uses. | |
| 7 | + | |
| 8 | +## How runs-on picks a runner | |
| 9 | + | |
| 10 | +Diffinery reads the job's `runs-on` labels (case-insensitive) and decides: | |
| 11 | + | |
| 12 | +1. If any label is `self-hosted`, the job waits for a self-hosted runner that offers **every** label in `runs-on`. See [Self-hosted runners](#self-hosted-runners). | |
| 13 | +2. Otherwise, if every label is one Diffinery hosts, the job runs on a hosted runner. | |
| 14 | +3. Otherwise the job fails at once with: | |
| 15 | + | |
| 16 | +```text | |
| 17 | +No Diffinery runner matches runs-on: windows-latest. Linux (ubuntu-*) runners are hosted; anything else needs runs-on: [self-hosted, ...] and a registered runner. | |
| 18 | +``` | |
| 19 | + | |
| 20 | +| Label in `runs-on` | Runs on | | |
| 21 | +|---|---| | |
| 22 | +| `ubuntu-latest`, `ubuntu-24.04`, `ubuntu-22.04`, or any other `ubuntu-*` label | Hosted runner (always the same Ubuntu 24.04, x64 image) | | |
| 23 | +| `linux` | Hosted runner | | |
| 24 | +| `x64`, `amd64` | Hosted runner | | |
| 25 | +| `diffinery`, or any `diffinery-*` label | Hosted runner | | |
| 26 | +| `self-hosted` (plus any labels you add) | A self-hosted runner offering all the labels | | |
| 27 | +| Anything else, for example `windows-latest`, `macos-latest`, `macos-14`, `gpu` without `self-hosted` | The job fails with the message above | | |
| 28 | + | |
| 29 | +Things to know: | |
| 30 | + | |
| 31 | +- Every `ubuntu-*` label gets the same Ubuntu 24.04 x64 image, including `ubuntu-22.04` and labels that ask for Arm, such as `ubuntu-24.04-arm`. | |
| 32 | +- `runs-on` can be a string, a list, or a mapping with `labels`. A `group` in the mapping form is ignored; only `labels` count. | |
| 33 | +- `runs-on` is evaluated for each matrix combination, so an entry can be an expression using `github`, `inputs`, `vars`, `needs`, `matrix` and `strategy`. An expression can give one label or a list of labels. Empty and `null` values are dropped. | |
| 34 | +- If evaluating `runs-on` fails, only that matrix combination fails, with `Error evaluating the job for this matrix combination: <message>`. If no labels are left, the job fails with `No Diffinery runner matches runs-on: (no labels). ...`. | |
| 35 | + | |
| 36 | +```yaml | |
| 37 | +jobs: | |
| 38 | + test: | |
| 39 | + runs-on: ubuntu-latest # hosted | |
| 40 | + steps: | |
| 41 | + - run: echo "hosted" | |
| 42 | + build: | |
| 43 | + strategy: | |
| 44 | + matrix: | |
| 45 | + target: [linux, macos] | |
| 46 | + # The linux leg runs on a hosted runner; the macos leg waits for a | |
| 47 | + # self-hosted runner with the macos label. | |
| 48 | + runs-on: ${{ matrix.target == 'macos' && fromJSON('["self-hosted","macos"]') || 'ubuntu-latest' }} | |
| 49 | + steps: | |
| 50 | + - run: uname -a | |
| 51 | + gpu: | |
| 52 | + runs-on: [self-hosted, linux, gpu] # waits for a runner started with --labels linux,gpu | |
| 53 | + steps: | |
| 54 | + - run: nvidia-smi | |
| 55 | +``` | |
| 56 | + | |
| 57 | +## Hosted runners | |
| 58 | + | |
| 59 | +Each job on a hosted runner gets its own container on [Cloudflare Containers](https://developers.cloudflare.com/containers/), started for that job and thrown away when it finishes. Nothing carries over between jobs except what you save with caches and artifacts (see [Caching and artifacts](/docs/actions/caching-and-artifacts)). | |
| 60 | + | |
| 61 | +### Machine | |
| 62 | + | |
| 63 | +| Property | Value | | |
| 64 | +|---|---| | |
| 65 | +| Platform | Cloudflare Containers. A large runner (instance type `standard-4`) has 4 vCPU, 12 GiB of memory and 20 GB of disk; see [Runner sizes](#runner-sizes) for the smaller ones | | |
| 66 | +| Architecture | `linux/amd64` (`runner.arch` is `X64`) | | |
| 67 | +| Operating system | Ubuntu 24.04 (`runner.os` is `Linux`) | | |
| 68 | +| User | `root`. `sudo` is installed and needs no password, so `sudo apt-get install ...` works as on GitHub | | |
| 69 | +| Network | Outbound internet access (only HTTP and HTTPS on instances that set [`EGRESS=web`](/docs/reference/limits#egress)); no inbound connections | | |
| 70 | +| Workspace | `/workspace/<repository name>` (`GITHUB_WORKSPACE`) | | |
| 71 | +| Temp directory | `/workspace/_temp` (`RUNNER_TEMP`) | | |
| 72 | +| Tool cache | `/opt/hostedtoolcache` (`RUNNER_TOOL_CACHE`, `AGENT_TOOLSDIRECTORY`) | | |
| 73 | +| Locale | `LANG=C.UTF-8`, `LC_ALL=C.UTF-8` | | |
| 74 | +| Concurrency | Up to 20 hosted jobs at once per account by default, across all its repositories, like GitHub's Free plan; further jobs stay queued and start, oldest first, as running ones finish. See [Limits](/docs/reference/limits) | | |
| 75 | + | |
| 76 | +### Runner sizes | |
| 77 | + | |
| 78 | +Hosted runners come in four sizes. Every size runs the same image, so a job behaves the same on each; only the machine differs. | |
| 79 | + | |
| 80 | +| Size | `runs-on` label | vCPU | Memory | Disk | Cloudflare instance type | | |
| 81 | +|---|---|---|---|---|---| | |
| 82 | +| large | `diffinery-large` | 4 | 12 GiB | 20 GB | `standard-4` | | |
| 83 | +| medium | `diffinery-medium` | 2 | 8 GiB | 16 GB | `standard-3` | | |
| 84 | +| small | `diffinery-small` | 1/2 | 4 GiB | 8 GB | `standard-1` | | |
| 85 | +| tiny | `diffinery-tiny` | 1/4 | 1 GiB | 4 GB | `basic` | | |
| 86 | + | |
| 87 | +A job with one of these labels in `runs-on` gets that size (with several, the largest): `runs-on: [ubuntu-latest, diffinery-small]`, or the label alone. A job without one gets **large**, unless its own earlier runs show a smaller size serves it just as well. That's how Diffinery keeps hosted minutes cheap: Cloudflare bills a container's memory and disk for what its size provides, used or not, so a deploy job that waits on another service for a minute costs a large runner's memory for that minute. | |
| 88 | + | |
| 89 | +How a size is picked for a job without a label: | |
| 90 | + | |
| 91 | +- The runner measures every hosted job: busy cores, memory and disk in use every quarter second, and processes the kernel killed for want of memory. The last lines of the job's log say what it used, for example `Resources: 0.1 of 4 vCPUs busy on average (0.3 at the 95th percentile), peak memory 0.3 GiB of 12.0 GiB, peak disk 1.6 GB of 19.3 GB`. The job API (`GET /repos/{owner}/{repo}/actions/jobs/{job_id}`) returns it as `usage`, and the size as `runner_size`. | |
| 92 | +- The next run of the same job (the same workflow file and job name, so each matrix leg on its own) looks at up to its last 5 runs, and needs at least 3. It gets a smaller size only if every one of them would have fit it with room to spare: peak memory times 1.5, plus 0.5 GiB, and peak disk times 1.25, plus 1 GB, within the size; and at least half as many vCPUs again as the job kept busy in its busiest quarter second, so a job that bursts onto several cores even briefly, such as a test suite, keeps a runner with room for that. Busy cores count as on the slowest host processor runners land on: Cloudflare's hosts mix AMD EPYC generations whose cores differ about 2x in speed, so a job measured on a fast host gets cores to spare for a slow one (a run whose host processor isn't known counts as on the fastest). A job that kept its runner's CPU busy to the limit never gets a smaller size than that one. | |
| 93 | +- A job stays on large when it has no measured runs yet, when one of those runs ran out of memory, when one failed or timed out on a smaller size, or when one took longer on a smaller size than the job takes on large allowing for its own run-to-run noise there: more than a tenth plus 2 seconds over the median of its large runs (on the same host processor where there is one), and slower than all but one of its last ten large runs. A job whose earlier runs were measured before quarter-second measuring (October 16, 2026) is sized again from new runs. After a smaller size ran a job out of memory, failed it or slowed it, the job stays on large for its next 20 runs, so it doesn't move back and forth. A re-run always runs on large, and so does a job that found no smaller runner free when it first started. So a smaller size is never the reason a job fails or slows down twice. | |
| 94 | +- The job's log names the size it got and why, near the top: `Runner size: tiny (0.25 of a vCPU, 1 GiB of memory, 4 GB of disk), sized by this job's last 5 runs (peak memory 0.3 GiB)`. | |
| 95 | + | |
| 96 | +Set `runs-on: diffinery-large` to keep a job on large whatever it uses. | |
| 97 | + | |
| 98 | +### What hosted jobs cost, and keeping it low | |
| 99 | + | |
| 100 | +A hosted runner exists only while its job runs: Diffinery starts a fresh container when the job starts and stops it when the job ends, and Cloudflare bills it by the 10 milliseconds, with no rounding up to whole minutes. Its memory and disk are billed for what the size provides, and its CPU for what the job uses. Nothing runs or bills between jobs. | |
| 101 | + | |
| 102 | +What that means for your workflows: | |
| 103 | + | |
| 104 | +- **CPU-bound jobs** (builds, test suites) are best on large, where they finish fastest; their cost follows the CPU they use, so a faster machine costs about the same per job. Automatic sizing keeps them there. | |
| 105 | +- **Jobs that wait** (deploys, API calls, polling, `sleep`) pay mostly for memory. If you know a job waits, pin a size: `runs-on: [ubuntu-latest, diffinery-small]` costs about a third of large per minute and `diffinery-tiny` about a tenth. Automatic sizing moves a job only when its own measured runs leave room to spare even on the slowest hosts. A job that checks out and then calls an API and waits typically moves to medium after its third measured run, about a third less per minute at the same speed; steps such as `actions/setup-node` or installing a CLI keep a core busy for a few seconds, which keeps a job on large. Pin a size to go further. | |
| 106 | +- **Superseded runs** keep running unless the workflow says otherwise, as on GitHub: a new push to a pull request doesn't cancel the run for the commit before it. To stop paying for runs nobody will look at, cancel them with a concurrency group per workflow and branch: | |
| 107 | + | |
| 108 | + ```yaml | |
| 109 | + concurrency: | |
| 110 | + group: ${{ github.workflow }}-${{ github.ref }} | |
| 111 | + cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| 112 | + ``` | |
| 113 | + | |
| 114 | + This cancels a pull request's older runs when a new commit arrives, and leaves runs on your default branch (such as deploys) to finish. It isn't on by default, because cancelling a run can cut off a deploy halfway or turn an older commit's checks into "cancelled". See [concurrency](/docs/actions/workflow-syntax#concurrency). | |
| 115 | + | |
| 116 | +### The Ubuntu 24.04 image | |
| 117 | + | |
| 118 | +The hosted image is built from `containers/runner/Dockerfile` in the Diffinery source. It is deliberately small. This is the complete list of what is preinstalled: | |
| 119 | + | |
| 120 | +| Group | Installed | | |
| 121 | +|---|---| | |
| 122 | +| Base tools | `ca-certificates`, `curl`, `git`, `git-lfs`, `jq`, `zip`, `unzip`, `xz-utils`, `tar`, `gzip`, `zstd`, `sudo`, `pkg-config`, `openssh-client`, `gnupg` | | |
| 123 | +| GitHub CLI | `gh`, from GitHub's apt repository | | |
| 124 | +| Build tools | `build-essential` (gcc, g++, make and the C library headers) | | |
| 125 | +| Libraries for Ruby and native extensions | `libyaml-dev`, `libssl-dev`, `libffi-dev`, `zlib1g-dev`, `libreadline-dev`, `libgmp-dev`, so `ruby/setup-ruby`'s prebuilt Rubies run and gems with C extensions build | | |
| 126 | +| Python | Ubuntu's `python3` (3.12), `python3-pip`, `python3-venv`, `python-is-python3` (so `python` runs `python3`) | | |
| 127 | +| Node.js | Node.js 22.22.0 with npm, plus corepack shims activated for pnpm 10.28.0 and yarn 1.22.22; nvm 0.40.7 in `/usr/local/nvm` (`NVM_DIR`), loaded in login shells such as `shell: bash -l {0}`, as on GitHub | | |
| 128 | +| Docker | Docker Engine (`docker-ce`), the Docker CLI, `containerd.io`, the Buildx plugin, the Compose plugin, `iptables` | | |
| 129 | + | |
| 130 | +Image settings that affect your steps: | |
| 131 | + | |
| 132 | +- `git config --system safe.directory '*'` is set, and Git LFS is installed system-wide. | |
| 133 | +- `PIP_BREAK_SYSTEM_PACKAGES=1` is set, so `pip install` works without a virtual environment, as on GitHub's hosted runners. | |
| 134 | +- `COREPACK_ENABLE_DOWNLOAD_PROMPT=0` is set, so corepack never stops to ask. | |
| 135 | +- `ImageOS=ubuntu24` and `ImageVersion=20250101.1` are set, as on GitHub's `ubuntu-24.04` image. `actions/setup-go` and `actions/setup-python` include them in their cache keys. | |
| 136 | +- `zstd` is installed, so `actions/cache` compresses with zstd, as on GitHub. | |
| 137 | +- The apt package lists are removed to keep the image small. Run `apt-get update` before `apt-get install`. | |
| 138 | + | |
| 139 | +Anything else (Go, Java, Ruby, Rust, .NET, PowerShell, browsers, cloud CLIs) is **not** preinstalled. Install it in a step: | |
| 140 | + | |
| 141 | +```yaml | |
| 142 | +steps: | |
| 143 | + - uses: actions/setup-go@v5 # downloads Go from go.dev | |
| 144 | + with: | |
| 145 | + go-version: "1.23" | |
| 146 | + - run: apt-get update && apt-get install -y --no-install-recommends openjdk-17-jdk-headless | |
| 147 | + - run: pip install awscli | |
| 148 | +``` | |
| 149 | + | |
| 150 | +`actions/setup-node`, `actions/setup-python` and `actions/setup-go` use the preinstalled Node.js or Python when its version satisfies the one you ask for, and download the version otherwise. See [Compatibility with GitHub Actions](/docs/actions/compatibility) for their supported inputs. | |
| 151 | + | |
| 152 | +**Go and Java start installing as the job begins.** When a job has an `actions/setup-go` step (v4 to v6) or an `actions/setup-java` step (v2 to v5, `distribution: temurin`, a major version such as `java-version: 21`), and the version is written in the workflow or comes from `matrix`, `inputs` or `vars`, the runner starts downloading and extracting that toolchain as the job begins, while earlier steps such as checkout run. The setup step then finds it in the tool cache instead of downloading it. If it's still being installed when the step starts, the step logs `Waiting for Go 1.23, which the runner started installing as the job began` and continues once it's there. The runner installs the same release the action would pick, checks it against its published SHA-256, and puts it where the action looks (`setup-java` reports `Resolved Java ... from tool-cache`, as it does on GitHub's runners), so nothing else about the step changes. Jobs without these steps don't pay for any of this. Versions read from a file (`go-version-file`, `java-version-file`), other Java distributions, and jobs that run in a `container:` install the usual way. | |
| 153 | + | |
| 154 | +> **Note:** Jobs run as `root`, and `sudo` runs commands without asking for a password, as on GitHub's runners. The image ships without apt package lists, so run `sudo apt-get update` before `sudo apt-get install ...`. | |
| 155 | + | |
| 156 | +### Docker on hosted runners | |
| 157 | + | |
| 158 | +Docker works on hosted runners the same way as on GitHub's: job containers, service containers, Docker container actions, and `docker build`, `docker run` and `docker compose` in `run:` steps. Details: | |
| 159 | + | |
| 160 | +- **Started on first use.** The `docker` command is a wrapper that starts the Docker daemon the first time anything in the job needs it (a `docker` command in a step, a job `container:`, `services:`, or a Docker action). The first use prints `Starting the Docker daemon` and takes about 1 to 2 seconds (the daemon and the registry relay below start together); jobs that never touch Docker don't pay for it. A job that will use Docker (a job `container:`, `services:`, a `docker://` action, or a step whose `uses:` or `run:` names `docker`) gets the daemon started in the background as the job begins, so those seconds overlap checkout and setup. Docker commands that arrive while the daemon is starting wait for it. If the daemon can't start, or another command has been starting it for over 2 minutes, the step fails with `The Docker daemon did not start:` followed by the end of the daemon's log, rather than waiting until the job's timeout. | |
| 161 | +- **Docker Hub through `mirror.gcr.io`.** The daemon uses `https://mirror.gcr.io` as its registry mirror, so pulls of Docker Hub images (`redis:7-alpine`, `postgres:16`) usually come from Google's public mirror and avoid Docker Hub's anonymous rate limits. Images the mirror doesn't have are pulled from Docker Hub as usual. | |
| 162 | +- **DNS.** The daemon gives containers the DNS servers `1.1.1.1` and `8.8.8.8`. | |
| 163 | +- **Pushing to the Diffinery registry.** The daemon and BuildKit builders reach `diffinery.com` through a local relay that splits image layer uploads into chunks, so layers larger than Cloudflare's 100 MB request limit push from hosted workflows. See [Container registry](/docs/packages/container-registry). | |
| 164 | +- **Buildx builders.** `docker buildx create` with the `docker-container` driver (which `docker/setup-buildx-action` runs) is handled by the wrapper: it starts the BuildKit container on the host network and registers it as a remote builder, because the normal driver can't work inside a Cloudflare Container. `docker buildx rm` removes it. | |
| 165 | + | |
| 166 | +The full guide is [Docker in workflows](/docs/actions/docker). | |
| 167 | + | |
| 168 | +### Environment variables | |
| 169 | + | |
| 170 | +Hosted and self-hosted runners set the GitHub-compatible variables (`CI=true`, `GITHUB_ACTIONS=true`, `GITHUB_REPOSITORY`, `GITHUB_SHA`, `GITHUB_REF`, `GITHUB_WORKSPACE`, `GITHUB_ENV`, `GITHUB_OUTPUT`, and so on) plus `DIFFINERY=true`. `GITHUB_SERVER_URL` is `https://diffinery.com` and `GITHUB_API_URL` is `https://diffinery.com/api/v3`. `RUNNER_NAME` is `diffinery` and `RUNNER_ENVIRONMENT` is `diffinery-hosted` on every runner. Workflows can override `CI` and `DIFFINERY`, but not `GITHUB_*` or `RUNNER_*` variables. | |
| 171 | + | |
| 172 | +Other variables a job gets: | |
| 173 | + | |
| 174 | +- `ImageOS` and `ImageVersion`, on hosted and self-hosted runners alike (`ubuntu24` and `20250101.1` unless the runner's environment sets them). | |
| 175 | +- `RUNNER_DEBUG=1` when the secret or variable `ACTIONS_STEP_DEBUG` or `ACTIONS_RUNNER_DEBUG` is `true`. See [Expressions and contexts](/docs/actions/expressions#default-environment-variables). | |
| 176 | +- `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_RUNTIME_URL`, `ACTIONS_RESULTS_URL`, `ACTIONS_CACHE_URL` and `ACTIONS_CACHE_SERVICE_V2`, but only in JavaScript actions, Docker container actions and the built-in actions, never in `run:` steps. They let actions such as `actions/cache` and `actions/upload-artifact` use the cache and artifact services. See [Caching and artifacts](/docs/actions/caching-and-artifacts#the-runtime-services). | |
| 177 | + | |
| 178 | +### Limits and timeouts | |
| 179 | + | |
| 180 | +| Limit | Value | What happens | | |
| 181 | +|---|---|---| | |
| 182 | +| Job timeout | `timeout-minutes`, default 360 minutes, and values above 360 are capped at 360 | The job fails with "The job running on runner has exceeded the maximum execution time of N minutes." | | |
| 183 | +| Total wait per job | 6 hours from when the job is scheduled, including time spent waiting for a runner | The job fails with "The job exceeded its timeout of N minutes." | | |
| 184 | +| Step timeout | `steps[*].timeout-minutes`, no default | The step fails with "The step has timed out after N minutes." | | |
| 185 | +| Runner that stops responding | 10 minutes past the job timeout | The runner ends a job at its timeout itself and reports within seconds. If it hasn't reported 10 minutes later (its agent froze or its machine vanished), Diffinery stops the container and the job fails as timed out with "The job exceeded its timeout of N minutes, and its runner stopped responding: Diffinery ended it 10 minutes later." So a stuck runner holds a hosted container, a slot of the account's hosted jobs and its minutes for at most the job's timeout plus 10 minutes. Set `timeout-minutes` on jobs that should never run long. | | |
| 186 | +| Matrix size | 256 jobs per matrix | The job fails with "A matrix may produce at most 256 jobs." | | |
| 187 | +| Log line length | 16,384 characters | Longer lines are cut and end with `…` | | |
| 188 | +| Log size | 16 MiB of text per job | The log ends with "The log is cut off here: a job's log is limited to 16 MiB of text." Later lines are dropped; the job keeps running. | | |
| 189 | +| Platform updates | A deploy is set up not to replace the container of a running job, so running jobs normally finish on the version they started with. The platform doesn't guarantee it, though. If it replaces a container anyway, its stop signal reaches the running step too. The same goes when the platform stops or loses a job's container without warning (it ends with no exit code, for example when its host fails). Diffinery then starts the job again from the beginning on a new runner, up to twice, if no step but `actions/checkout` had started or if the job can only read: it uses no secrets (other than `GITHUB_TOKEN`), no deployment environment, and its token can't write. What the stopped attempt uploaded (artifacts, caches) is dropped first, as a re-run would. A job that could change something outside the run fails instead once another step has started, because running that step twice could repeat what it did (a deploy, a publish). Re-run it from the run page | The log shows "The platform is stopping this runner" and, on the step, "The platform stopped this step's processes while stopping the runner." A restarted job's log starts over with "Diffinery started the job again from the beginning on a new runner (restart N of 2)". A failed one says "The platform replaced this job's runner with a new version while step … ran" when a deploy replaced it (the runner was on an older image), or "The platform stopped this job's container while step … ran" when it ran the deployed image (or, after the third replacement, "… 3 times") | | |
| 190 | + | |
| 191 | +If the container stops unexpectedly (for example it runs out of memory), the job fails with `The runner stopped unexpectedly (exit code N).` A container that stops before its runner fetched the job is replaced instead, since no step ran. | |
| 192 | + | |
| 193 | +The log's first lines name where the job ran, for example `Runner: image diffinery-runner:96dbbe31790f, in fra06, EEUR, container 6f1c…`. On hosted runners a `Host:` line names the host's processor (Cloudflare's hosts mix AMD EPYC generations, so the same job can run up to about twice as fast on one host as on another), and the line before the job's conclusion says how much of the vCPUs' time the host gave to other work (steal). The jobs API returns the same as `runner` (`image`, `location`, `instance`, and `cpu` on hosted runners), next to `runner_name`. `GET /meta` names the image the last deploy set as `runner_image`. While a deploy's rollout is updating instances, a new container can still start on the previous image. Diffinery turns that runner away before it runs anything and starts the job again on another container (after 30 s, then 1, 2 and 4 minutes). After four tries, the job runs on whatever image it got rather than wait for a rollout that has stalled. The jobs API's `placementTries` counts the times a job went back in line before a runner took it (each time, the job's log gets a line saying why: no machine was free, the container stopped before the runner started, or a runner on the previous image was turned away), and `restarts` counts the times it started again because the platform replaced its runner. | |
| 194 | + | |
| 195 | +### Windows and macOS | |
| 196 | + | |
| 197 | +Cloudflare Containers run Linux only, so Diffinery has no Windows or macOS hosted runners. `runs-on: windows-latest` or `runs-on: macos-latest` fails with the "No Diffinery runner matches runs-on" message. For macOS builds, Windows-specific tooling or GPUs, use a [self-hosted runner](#self-hosted-runners) on your own machine with `runs-on: [self-hosted, ...]`. | |
| 198 | + | |
| 199 | +## Self-hosted runners | |
| 200 | + | |
| 201 | +A self-hosted runner is the `diffinery runner` command running on a machine you control. It polls one or more repositories for waiting jobs whose `runs-on` includes `self-hosted`, claims one, runs it with the Diffinery runner agent (the same program that runs inside hosted containers), streams the logs to Diffinery, and reports the result. Use it for: | |
| 202 | + | |
| 203 | +- **macOS**: build and sign iOS or macOS apps with Xcode on a Mac. | |
| 204 | +- **GPUs**: run CUDA tests or model evaluation on a machine with a GPU. | |
| 205 | +- **Private networks**: deploy to or test against servers that Cloudflare can't reach (a database behind a VPN, an on-premises cluster). | |
| 206 | +- **Long-lived caches and big toolchains**: the runner's work directory and tool cache persist between jobs. | |
| 207 | + | |
| 208 | +### Requirements | |
| 209 | + | |
| 210 | +- **Node.js 22 or later** on the machine. | |
| 211 | +- **The `diffinery` CLI.** Install it with: | |
| 212 | + | |
| 213 | + ```sh | |
| 214 | + curl -fsSL https://diffinery.com/install | bash | |
| 215 | + ``` | |
| 216 | + | |
| 217 | +- **The runner agent**, a single Node.js file named `agent.mjs`. Build it from a checkout of the Diffinery source: | |
| 218 | + | |
| 219 | + ```sh | |
| 220 | + pnpm install | |
| 221 | + pnpm --filter @diffinery/runner build # produces containers/runner/dist/agent.mjs | |
| 222 | + ``` | |
| 223 | + | |
| 224 | + Copy `containers/runner/dist/agent.mjs` to the runner machine (for example to `/opt/diffinery/agent.mjs`) and pass it with `--agent` or the `DIFFINERY_RUNNER_AGENT` environment variable. | |
| 225 | +- **A registration token** for each repository the runner serves, from a person with the **admin** role on it. A registration token works for an hour, and the runner trades it for its own **runner token**, which can claim that repository's jobs and do nothing else. Agents, job tokens and people without admin can't create one (`403 only repository admins can add self-hosted runners`). | |
| 226 | +- **Bash** (the default shell for `run:` steps; `sh` is used when bash is missing), `git` and `tar`, which the built-in actions use. | |
| 227 | +- **Docker**, only if jobs use `container:`, `services:` or Docker actions. | |
| 228 | + | |
| 229 | +### Start a runner | |
| 230 | + | |
| 231 | +1. On your own computer, as a repository admin, create a registration token (it works for an hour): | |
| 232 | + | |
| 233 | + ```sh | |
| 234 | + diffinery api POST /repos/acme/website/actions/runners/registration-token | |
| 235 | + ``` | |
| 236 | + | |
| 237 | + ```json | |
| 238 | + {"token": "drg_…", "expires_at": "2026-10-13T19:45:00.000Z"} | |
| 239 | + ``` | |
| 240 | + | |
| 241 | +2. On the runner machine, register and start the runner. Set `DIFFINERY_URL` if you don't use `https://diffinery.com`. No login is needed: | |
| 242 | + | |
| 243 | + ```sh | |
| 244 | + diffinery runner -R acme/website --registration-token drg_… --labels macos,arm64 --agent /opt/diffinery/agent.mjs | |
| 245 | + ``` | |
| 246 | + | |
| 247 | + It registers, saves the runner's credentials, prints the labels it offers and waits: | |
| 248 | + | |
| 249 | + ```text | |
| 250 | + Registered runner my-mac with acme/website (saved to ~/.config/diffinery/runners/diffinery.com/acme/website/my-mac.json) | |
| 251 | + Runner my-mac offering [self-hosted, macos, arm64] for acme/website | |
| 252 | + Waiting for jobs. Press Ctrl-C to stop. | |
| 253 | + ``` | |
| 254 | + | |
| 255 | + Later starts use the saved registration: `diffinery runner -R acme/website --agent /opt/diffinery/agent.mjs`. Its labels are the ones it registered with. To change them, delete the saved file and register again. | |
| 256 | + | |
| 257 | + On a machine where you're signed in as a repository admin (`diffinery login`), `diffinery runner` registers itself with your token and claims jobs with the runner's own token. Prefer a registration token on shared or dedicated runner machines, so no personal token is kept where jobs could read it. | |
| 258 | + | |
| 259 | +3. Target it from a workflow: | |
| 260 | + | |
| 261 | + ```yaml | |
| 262 | + jobs: | |
| 263 | + ios: | |
| 264 | + runs-on: [self-hosted, macos, arm64] | |
| 265 | + steps: | |
| 266 | + - uses: actions/checkout@v4 | |
| 267 | + - run: xcodebuild -scheme App -destination 'platform=iOS Simulator,name=iPhone 16' test | |
| 268 | + ``` | |
| 269 | + | |
| 270 | +When a job arrives the runner prints `→ acme/website: running job ios (run <run id>, job <job id>)`, echoes the step output prefixed with the job name, and ends with `✓ job ios finished` or `X job ios finished with exit code 1`. | |
| 271 | + | |
| 272 | +### diffinery runner options | |
| 273 | + | |
| 274 | +| Flag | Short | Default | Description | | |
| 275 | +|---|---|---|---| | |
| 276 | +| `--repo OWNER/NAME` | `-R` | The repository of the current directory's git `origin` remote | Repository to take jobs from. Repeat the flag to serve several repositories. | | |
| 277 | +| `--labels a,b` | `-l` | none | Extra labels this runner offers, comma separated. `self-hosted` is always offered. | | |
| 278 | +| `--name NAME` | `-n` | The machine's host name | Runner name. Shown on claimed jobs and used for the default work directory. | | |
| 279 | +| `--once` | | off | Run at most one job, then exit with its exit code. Prints "No jobs waiting." and exits 0 if nothing is waiting. | | |
| 280 | +| `--agent PATH` | `-a` | `DIFFINERY_RUNNER_AGENT` | Path to the runner agent (`agent.mjs`). Required one way or the other. | | |
| 281 | +| `--work-dir DIR` | `-w` | `~/.cache/diffinery/runner/<name>` (or `$XDG_CACHE_HOME/diffinery/runner/<name>`) | Working directory for jobs. | | |
| 282 | +| `--interval SECONDS` | | `5` | Seconds between polls when idle. | | |
| 283 | +| `--quiet` | `-q` | off | Don't echo step output locally. Logs still go to Diffinery. | | |
| 284 | +| `--registration-token TOKEN` | | `DIFFINERY_RUNNER_REGISTRATION_TOKEN` | Register this runner with one repository, using a registration token from a repository admin. | | |
| 285 | +| `--ephemeral` | | off | Register for a single job: the runner's token stops working once it takes a job, the runner exits with the job's exit code, and nothing is saved. Use it in throwaway VMs or containers. | | |
| 286 | + | |
| 287 | +The full generated reference is at [CLI reference](/docs/reference/cli). | |
| 288 | + | |
| 289 | +### Labels and job matching | |
| 290 | + | |
| 291 | +- A job is eligible when its `runs-on` contains `self-hosted` and **every** one of its labels is offered by the runner. The runner offers `self-hosted` plus everything in `--labels`. Comparison is case-insensitive. | |
| 292 | +- A runner with more labels than a job asks for can still take the job: `--labels linux,gpu,cuda12` takes `runs-on: [self-hosted, gpu]`. | |
| 293 | +- `runs-on: [self-hosted]` alone can be taken by any runner serving the repository. | |
| 294 | +- When several jobs are waiting, a runner takes the oldest run's jobs first. | |
| 295 | +- Labels have no meaning to Diffinery beyond matching. Choose names that describe the machine (`macos`, `arm64`, `gpu`, `vpn-prod`). | |
| 296 | + | |
| 297 | +### How jobs are claimed | |
| 298 | + | |
| 299 | +1. When a self-hosted job is ready to start, Diffinery marks it **waiting** and creates a queued check on the commit. | |
| 300 | +2. Each runner polls `POST /api/v3/repos/{owner}/{repo}/actions/runners/claim` with its runner token, every `--interval` seconds per repository while idle. It offers the labels it registered with. The response is `204 No Content` when nothing matches, or the job with a fresh job token: | |
| 301 | + | |
| 302 | + ```http | |
| 303 | + POST /api/v3/repos/acme/website/actions/runners/claim | |
| 304 | + Authorization: Bearer drn_... | |
| 305 | + Content-Type: application/json | |
| 306 | + | |
| 307 | + {} | |
| 308 | + ``` | |
| 309 | + | |
| 310 | + ```json | |
| 311 | + {"job_id": "…", "run_id": "…", "name": "ios", "token": "djt_…", "api_url": "https://diffinery.com"} | |
| 312 | + ``` | |
| 313 | + | |
| 314 | +3. A claimed job belongs to that runner; no other runner can take it. The runner starts the agent with `DIFFINERY_API_URL`, `DIFFINERY_JOB_ID` and `DIFFINERY_JOB_TOKEN`, and the agent fetches the job, runs it and reports back. | |
| 315 | +4. A runner runs one job at a time. To run jobs in parallel on one machine, start several runners with different `--name` values (each gets its own work directory). | |
| 316 | + | |
| 317 | +A job that no runner claims fails after 6 hours. While a job waits, the run page shows it as queued. | |
| 318 | + | |
| 319 | +If the runner can't reach Diffinery it prints `warning: ... (will retry)` and keeps polling. A `401`, `403` or `404` from the claim request stops the runner, because retrying won't help. For a removed runner it says `runner <name> was removed from <owner>/<repo>; delete <file> and register it again`. | |
| 320 | + | |
| 321 | +Only registered runners can claim jobs. A personal token, even a repository admin's, gets `403` with `personal tokens can't claim jobs; register this runner: ...`. Runners used to claim that way before registration. If an older runner stops with that message, upgrade the CLI and start it again while signed in, which registers it, or register it with `--registration-token`. | |
| 322 | + | |
| 323 | +### Manage runners | |
| 324 | + | |
| 325 | +| Endpoint | Does | Who | | |
| 326 | +|---|---|---| | |
| 327 | +| `POST /repos/{owner}/{repo}/actions/runners/registration-token` | A registration token, valid for an hour and for any number of runners: `{"token": "drg_…", "expires_at": "…"}` | Repository admins (people) | | |
| 328 | +| `POST /repos/{owner}/{repo}/actions/runners/register` | Register a runner: send `{"registration_token": "drg_…", "name": "my-mac", "labels": ["macos"], "ephemeral": false}` and get `{"id", "name", "labels", "ephemeral", "token": "drn_…"}`. The runner token is shown once. A wrong or expired registration token, or a repository that doesn't exist, gets `401 invalid or expired registration token`. | The registration token is the credential | | |
| 329 | +| `GET /repos/{owner}/{repo}/actions/runners` | GitHub's runner list: `id`, `name`, `os`, `status` (`online` when it polled in the last 90 seconds), `busy`, `ephemeral` and `labels` | Repository admins | | |
| 330 | +| `DELETE /repos/{owner}/{repo}/actions/runners/{id}` | Remove a runner. Its token stops working at once; a job it's running carries on with its own job token. | Repository admins (people) | | |
| 331 | + | |
| 332 | +A repository can have at most 100 registered runners, each with at most 20 labels. An ephemeral runner is removed when its job finishes. Registering a runner, creating a registration token and removing a runner are recorded in the audit log. | |
| 333 | + | |
| 334 | +### What a job sees on a self-hosted runner | |
| 335 | + | |
| 336 | +- The job runs **directly on the machine**, as the user that started `diffinery runner`, with that user's files, network and permissions. | |
| 337 | +- The job's environment is the runner's environment, minus `DIFFINERY_TOKEN`, `DIFFINERY_URL`, `DIFFINERY_RUNNER_AGENT`, `GH_TOKEN` and `GITHUB_TOKEN`, plus the usual `GITHUB_*` and `RUNNER_*` variables. `DIFFINERY_GITHUB_TOKEN` (see below) is used by the agent to download actions but is removed from every step's environment. | |
| 338 | +- The workspace is `<work dir>/<repository name>` and is **not** wiped between jobs. `actions/checkout` cleans it by default (`clean: true`). | |
| 339 | +- The tool cache (`RUNNER_TOOL_CACHE`) is `<work dir>/_tool` unless the runner's environment sets `RUNNER_TOOL_CACHE`. Tools that `setup-node`, `setup-python` and `setup-go` download stay there for later jobs. | |
| 340 | +- `runner.os` reports `Linux` and `RUNNER_ENVIRONMENT` reports `diffinery-hosted` on every machine, including Macs. Use your own labels, or check `uname` in a step, if a workflow needs to know the platform. | |
| 341 | +- Remote actions (`uses: owner/repo@ref`) are downloaded from `codeload.github.com`. Set `DIFFINERY_GITHUB_TOKEN` in the runner's environment to authenticate those downloads (useful against GitHub's rate limits), or `DIFFINERY_ACTIONS_DOWNLOAD_URL` to a URL template containing `{owner}`, `{repo}` and `{ref}` to download from a mirror instead. If the download from `codeload.github.com` fails, the agent tries a shallow `git fetch` of the ref from `https://github.com/<owner>/<repo>` before failing the step with `Failed to download action '<owner>/<repo>@<ref>': ...`. There is no `git` fallback when `DIFFINERY_ACTIONS_DOWNLOAD_URL` is set. | |
| 342 | +- The common official actions (`actions/checkout`, `actions/cache`, `actions/upload-artifact`, `actions/download-artifact`, `actions/setup-node`, `actions/setup-python`, `actions/setup-go`) run as built-in implementations when the version and inputs allow it. Set `DIFFINERY_DISABLE_BUILTINS=1` in the runner's environment to always download and run the real actions on that runner. See [Compatibility with GitHub Actions](/docs/actions/compatibility#built-in-actions). | |
| 343 | +- `setup-node`, `setup-python` and `setup-go` download their tools from `nodejs.org/dist`, the `actions/python-versions` manifest and `go.dev`. Override the sources with `DIFFINERY_NODE_MIRROR`, `DIFFINERY_PYTHON_MANIFEST` and `DIFFINERY_GO_INDEX` in the runner's environment if the machine can't reach them. | |
| 344 | + | |
| 345 | +### Run as a service with systemd | |
| 346 | + | |
| 347 | +On Linux, run the runner as a systemd service under a dedicated user so it starts at boot and restarts after failures. | |
| 348 | + | |
| 349 | +1. Create a user and put the CLI and agent where it can read them: | |
| 350 | + | |
| 351 | + ```sh | |
| 352 | + sudo useradd --system --create-home --shell /bin/bash diffinery-runner | |
| 353 | + sudo install -m 0755 ~/.local/bin/diffinery /usr/local/bin/diffinery | |
| 354 | + sudo install -D -m 0644 containers/runner/dist/agent.mjs /opt/diffinery/agent.mjs | |
| 355 | + ``` | |
| 356 | + | |
| 357 | +2. Register the runner once as that user, with a registration token from a repository admin. Stop it with Ctrl-C once it says it's waiting: | |
| 358 | + | |
| 359 | + ```sh | |
| 360 | + sudo -u diffinery-runner -H env DIFFINERY_URL=https://diffinery.com DIFFINERY_RUNNER_AGENT=/opt/diffinery/agent.mjs \ | |
| 361 | + diffinery runner -R acme/website --registration-token drg_… --labels linux,gpu --name gpu-box-1 | |
| 362 | + ``` | |
| 363 | + | |
| 364 | + Its credentials are saved in that user's `~/.config/diffinery/runners`, so the service needs no token. | |
| 365 | + | |
| 366 | +3. Create `/etc/systemd/system/diffinery-runner.service`: | |
| 367 | + | |
| 368 | + ```text | |
| 369 | + [Unit] | |
| 370 | + Description=Diffinery self-hosted runner | |
| 371 | + After=network-online.target | |
| 372 | + Wants=network-online.target | |
| 373 | + | |
| 374 | + [Service] | |
| 375 | + User=diffinery-runner | |
| 376 | + WorkingDirectory=/home/diffinery-runner | |
| 377 | + Environment=DIFFINERY_URL=https://diffinery.com | |
| 378 | + Environment=DIFFINERY_RUNNER_AGENT=/opt/diffinery/agent.mjs | |
| 379 | + ExecStart=/usr/bin/node /usr/local/bin/diffinery runner -R acme/website --labels linux,gpu --name gpu-box-1 --quiet | |
| 380 | + Restart=always | |
| 381 | + RestartSec=10 | |
| 382 | + | |
| 383 | + [Install] | |
| 384 | + WantedBy=multi-user.target | |
| 385 | + ``` | |
| 386 | + | |
| 387 | + Adjust the path to `node` (`which node`) and the repositories and labels. Pass `-R` explicitly: a service has no git checkout to infer the repository from. | |
| 388 | + | |
| 389 | +4. Start it and follow its output: | |
| 390 | + | |
| 391 | + ```sh | |
| 392 | + sudo systemctl daemon-reload | |
| 393 | + sudo systemctl enable --now diffinery-runner | |
| 394 | + journalctl -u diffinery-runner -f | |
| 395 | + ``` | |
| 396 | + | |
| 397 | +Stopping the service sends the stop signal to the runner and the agent, which cancels the running job and reports it as cancelled. On macOS, use a `launchd` agent with the same command line and environment variables. | |
| 398 | + | |
| 399 | +For a clean machine per job, run `diffinery runner --ephemeral --registration-token drg_…` inside a fresh virtual machine or container and throw it away afterwards. It exits with the job's exit code, and its token can't be used again. | |
| 400 | + | |
| 401 | +### Docker on self-hosted runners | |
| 402 | + | |
| 403 | +Job containers, service containers and Docker container actions work on a self-hosted runner that has Docker installed and running, with the `docker` CLI on the runner's `PATH` and permission to use the daemon (for example, membership in the `docker` group). Without Docker, those jobs fail with: | |
| 404 | + | |
| 405 | +```text | |
| 406 | +Job containers (jobs.<id>.container) need Docker, and this runner has no docker CLI. Use runs-on: [self-hosted, ...] on a machine with Docker installed. | |
| 407 | +``` | |
| 408 | + | |
| 409 | +or, when the CLI exists but the daemon isn't running: | |
| 410 | + | |
| 411 | +```text | |
| 412 | +Service containers (jobs.<id>.services) need Docker, and the Docker daemon on this runner is not reachable: ... | |
| 413 | +``` | |
| 414 | + | |
| 415 | +The daemon must be local to the machine, because the job container mounts the runner's work directory at the same path. Self-hosted runners don't have the registry relay that hosted runners have, so pushing an image layer larger than 100 MB to the Diffinery registry fails from a self-hosted runner. See [Docker in workflows](/docs/actions/docker) and [Container registry](/docs/packages/container-registry). | |
| 416 | + | |
| 417 | +### macOS, Windows and GPU machines | |
| 418 | + | |
| 419 | +- **macOS.** The runner and agent run on macOS with Node.js 22 or later. Steps run with `/bin/bash`. Because the built-in `setup-node`, `setup-python` and `setup-go` download Linux builds, install toolchains on the Mac yourself (Homebrew, Xcode) and either skip those actions or ask for a version that's already installed (the installed one is used when it satisfies the request). Docker-based features are designed for Linux hosts. | |
| 420 | +- **Windows.** The runner agent runs steps with bash or sh and rejects `shell: cmd` with "The `cmd` shell is not supported on Linux runners." It reports `runner.os` as `Linux`. Native Windows isn't a supported host in this version; the practical route is to run the runner inside WSL 2 and call Windows programs from steps through WSL interop (for example `powershell.exe`). | |
| 421 | +- **GPUs.** Start the runner with a label such as `gpu` and run steps directly on the machine. To use the GPU from a job container, install the NVIDIA Container Toolkit on the machine and pass the Docker option through: `container: { image: nvidia/cuda:12.4.1-base-ubuntu22.04, options: --gpus all }`. | |
| 422 | + | |
| 423 | +### Security considerations | |
| 424 | + | |
| 425 | +A self-hosted runner runs code from your repository's workflows on your machine. Before you start one: | |
| 426 | + | |
| 427 | +- **Anyone who can push can run code on the machine.** People and agents with write access can push a branch or open a pull request whose workflow targets `self-hosted`, and `push` and `pull_request` runs use the workflow file from that commit. Only serve repositories whose writers you trust with the machine. | |
| 428 | +- **Jobs run as the runner's user.** A job can read anything that user can, including the runner's saved registration and other repositories' checkouts in the work directory. A runner registered with a registration token keeps only its runner token, which can claim that repository's jobs and nothing else: it can't read the repository, push, or call the API. Someone who steals it can still take that runner's future jobs, with their secrets, until you remove the runner. So use a dedicated OS user with no other access, don't sign in with a personal token on the runner machine, and prefer `--ephemeral` in a throwaway VM for anything sensitive. | |
| 429 | +- **Jobs get the repository's secrets**, just like on hosted runners, and can reach whatever the machine can reach on your network. A job with `environment:` also gets that environment's secrets when its branch policy and the rules for environment secrets allow it, so a runner that serves deploy jobs handles production credentials. See [Deployments and environments](/docs/actions/environments). | |
| 430 | +- **State persists.** The work directory, tool cache and anything a job writes elsewhere stay on the machine. A malicious job can leave something behind for the next job. For untrusted workloads, use `--ephemeral` in a throwaway VM: its token ends when it takes its one job. | |
| 431 | +- **Cancelling isn't instant on your machine.** When a run is cancelled, Diffinery marks the job cancelled and revokes its job token right away, but the agent finishes executing the current steps locally (their logs and results are discarded). Stop the runner (Ctrl-C) to stop the work immediately; a second Ctrl-C kills the agent. | |
| 432 | + | |
| 433 | +## Troubleshooting | |
| 434 | + | |
| 435 | +| Symptom | Cause and fix | | |
| 436 | +|---|---| | |
| 437 | +| `No Diffinery runner matches runs-on: ...` | A label isn't hosted and the job doesn't include `self-hosted`. Use an `ubuntu-*` label, or add `self-hosted` and start a runner with matching `--labels`. | | |
| 438 | +| `No Diffinery runner matches runs-on: (no labels)` | Every `runs-on` entry evaluated to an empty value, for example a `matrix` key that the combination doesn't set. Check the expression. | | |
| 439 | +| A self-hosted job stays queued | No runner serving that repository offers every label. Compare the job's `runs-on` with the labels the runner printed at start. The job fails after 6 hours. | | |
| 440 | +| `403 admin access required` | The runner's token or account lacks admin on the repository, or the token isn't `admin`-scoped. | | |
| 441 | +| `no runner agent given` or `runner agent not found at ...` | Pass `--agent /path/to/agent.mjs` or set `DIFFINERY_RUNNER_AGENT`. Build it with `pnpm --filter @diffinery/runner build`. | | |
| 442 | +| `E: Unable to locate package ...` | Run `apt-get update` first; the image ships without package lists. | | |
| 443 | +| A hosted job starts a minute or more late, and its log begins with `No hosted runner could start the job yet: ...` | No machine was free (`there is no container instance that can be provided`), the start failed on the platform's side for a passing reason (`internal error; reference = ...`, a dropped connection), the container stopped before the runner started, or a deploy was rolling out and the runner came up on the previous image. Diffinery puts the job back in line and tries again (after 30 s, then 1, 2, 4 and 5 minutes, up to 8 times; a first container stop before the runner started, or a first internal error, is retried after 5 s and not counted; a runner on the previous image during a deploy is retried every 20 s for up to 10 minutes, not counted, and then runs on whichever image it got); `placementTries` in the jobs API counts the waits. Nothing to fix for an occasional wait. If a large matrix often waits, cap it with `strategy.max-parallel`. | | |
| 444 | +| `The platform replaced this job's runner with a new version while step ... ran` or `The platform stopped this job's container while step ... ran` | A deploy replaced the job's container mid-step (rare), or the platform stopped or moved it. Re-run the job. A job that can only read starts again by itself instead: give jobs that only build and test `permissions: contents: read`, no `environment:` and no secrets other than `GITHUB_TOKEN`. See Platform updates under [Limits and timeouts](#limits-and-timeouts). | | |
| 445 | +| `The runner stopped unexpectedly (exit code N).` | The hosted container exited, often from running out of memory. Reduce parallelism in the job (for example `make -j2`) or move it to a self-hosted runner. | | |
| 446 | +| `Connection refused` or `Connection timed out` on SSH (`git@...`), SMTP or a database port, on a hosted runner | The instance sets [`EGRESS=web`](/docs/self-hosting/configuration#egress), which lets hosted jobs use only HTTP and HTTPS. Use an `https://` remote or API, or run the job on a self-hosted runner. It's unset by default. | | |
| 447 | +| `unable to get local issuer certificate`, `x509: certificate signed by unknown authority` or another certificate error on a hosted runner | Under [`EGRESS=web`](/docs/self-hosting/configuration#how-a-blocked-connection-looks) HTTPS is re-signed with a Cloudflare CA. Most tools trust it already; a tool with its own CA list, or a Docker container the job starts, needs `/etc/cloudflare/certs/cloudflare-containers-ca.crt` added. | |
addedapps/diffinery/docs/actions/secrets-and-variables.md+477−0View file
| @@ -1,0 +1,477 @@ | ||
| 1 | +--- | |
| 2 | +title: Secrets and variables | |
| 3 | +description: Store credentials as encrypted secrets and settings as variables for workflows, per repository, organization or environment, and who can manage them. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +Secrets hold sensitive values such as API tokens and deploy keys; variables hold plain configuration such as a region or a feature flag. Workflows read them as `${{ secrets.NAME }}` and `${{ vars.NAME }}`, exactly as on GitHub. Secrets are encrypted at rest and masked in logs; variables are stored and shown in plain text. | |
| 7 | + | |
| 8 | +## What you can store | |
| 9 | + | |
| 10 | +| Kind | Scope | Manage in the web UI | Manage with the CLI and API | Who can manage | | |
| 11 | +|---|---|---|---|---| | |
| 12 | +| Repository secret | One repository | Yes | Yes | People with the admin role on the repository | | |
| 13 | +| Organization secret | Every repository the organization owns, its private ones, or the ones you select | No | Yes | Organization owners and admins, and site admins (people only) | | |
| 14 | +| Repository variable | One repository | Yes | Yes | People with the admin role on the repository | | |
| 15 | +| Organization variable | Every repository the organization owns | No | API only | Organization owners and admins, and site admins (people only) | | |
| 16 | +| Environment secret | Jobs of one repository that use the environment | Yes | API only | People with the admin role on the repository | | |
| 17 | +| Environment variable | Jobs of one repository that use the environment | No | API only | People with the admin role on the repository | | |
| 18 | + | |
| 19 | +Agents can't create, change or delete secrets or variables anywhere, whatever their role (see [Agents, secrets and variables](#agents-secrets-and-variables)). Environment secrets and variables are described in [Environment secrets and variables](#environment-secrets-and-variables) below and in full in [Deployments and environments](/docs/actions/environments). | |
| 20 | + | |
| 21 | +## Names | |
| 22 | + | |
| 23 | +- Names use letters, digits and underscores, and don't start with a digit (`^[A-Za-z_][A-Za-z0-9_]*$`). For repository variables, only the web form checks this (the organization variables API checks it too); the variables API accepts other names, which expressions may not be able to read, so keep to the same format. | |
| 24 | +- Names are stored in uppercase: saving `api_key` creates `API_KEY`. In expressions, `secrets.api_key` and `secrets.API_KEY` both find it. | |
| 25 | +- Repository and environment secrets, and environment and organization variables, can't start with `GITHUB_`. Saving one fails with `invalid secret name` (or `invalid variable name`). | |
| 26 | +- Don't name a secret `DIFFINERY_TOKEN` or `GITHUB_TOKEN`: in a job, both always hold the job's own token, which replaces any secret with the same name. | |
| 27 | + | |
| 28 | +## Repository secrets | |
| 29 | + | |
| 30 | +### Secrets in the web UI | |
| 31 | + | |
| 32 | +1. Open the repository and go to **Settings → Secrets and variables**. | |
| 33 | +2. Under **Repository secrets**, enter a **Name** and a **Value**. | |
| 34 | +3. Click **Save secret**. Setting an existing name replaces its value. | |
| 35 | + | |
| 36 | +The page lists each secret's name and when it was last updated. Values are never shown again. Click **Delete** next to a secret to remove it. | |
| 37 | + | |
| 38 | +### Secrets with the CLI | |
| 39 | + | |
| 40 | +The CLI needs to act as a person with admin access to the repository, with the `admin` scope. Install it and sign in: | |
| 41 | + | |
| 42 | +```sh | |
| 43 | +curl -fsSL https://diffinery.com/install | bash | |
| 44 | +diffinery login --scope admin | |
| 45 | +``` | |
| 46 | + | |
| 47 | +The value comes from `--body`, from stdin when piped, or from a hidden prompt: | |
| 48 | + | |
| 49 | +```sh | |
| 50 | +diffinery secret set CLOUDFLARE_API_TOKEN -R acme/website # prompts for the value | |
| 51 | +diffinery secret set NPM_TOKEN -R acme/website --body "$NPM_TOKEN" | |
| 52 | +diffinery secret set DEPLOY_KEY -R acme/website < ~/.ssh/deploy_key # multi-line values from a file | |
| 53 | +diffinery secret list -R acme/website | |
| 54 | +diffinery secret delete NPM_TOKEN -R acme/website | |
| 55 | +``` | |
| 56 | + | |
| 57 | +Without `-R`, the CLI uses the repository of the current directory's git `origin` remote. Passing a value with `--body` can leave it in your shell history; prefer stdin or the prompt. See [CLI reference](/docs/reference/cli) for every flag. | |
| 58 | + | |
| 59 | +### Secrets with the API | |
| 60 | + | |
| 61 | +```sh | |
| 62 | +# Create or replace a secret (204 No Content) | |
| 63 | +curl -X PUT https://diffinery.com/api/v3/repos/acme/website/actions/secrets/NPM_TOKEN \ | |
| 64 | + -H "Authorization: Bearer dfy_..." \ | |
| 65 | + -H "Content-Type: application/json" \ | |
| 66 | + -d '{"value": "npm_..."}' | |
| 67 | + | |
| 68 | +# List names (values are never returned) | |
| 69 | +curl https://diffinery.com/api/v3/repos/acme/website/actions/secrets \ | |
| 70 | + -H "Authorization: Bearer dfy_..." | |
| 71 | + | |
| 72 | +# Delete (204 No Content) | |
| 73 | +curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/actions/secrets/NPM_TOKEN \ | |
| 74 | + -H "Authorization: Bearer dfy_..." | |
| 75 | +``` | |
| 76 | + | |
| 77 | +The list response is `{"total_count": 1, "secrets": [{"name": "NPM_TOKEN", "updated_at": "..."}]}`. Unlike GitHub, the value is sent as plain JSON over HTTPS; there's no public key to encrypt it with first. A body that only has GitHub's sealed-box `encrypted_value` fails with ``422 encrypted_value (sealed box) is not supported yet: send the secret as `value` over HTTPS; it is encrypted at rest``, and there's no `public-key` endpoint, so tools that encrypt the value first don't work; send `value` instead. The token needs the `admin` scope and its owner needs the admin role on the repository. See [API reference](/docs/api) for the full endpoint list. | |
| 78 | + | |
| 79 | +## Organization secrets | |
| 80 | + | |
| 81 | +Organization secrets are available to workflows in the organization's repositories. As on GitHub, each secret has a `visibility` that says which repositories get it: | |
| 82 | + | |
| 83 | +- `all`: every repository the organization owns (the default for a new secret). | |
| 84 | +- `private`: its private and internal repositories, not its public ones. | |
| 85 | +- `selected`: only the repositories you list. | |
| 86 | + | |
| 87 | +They're managed with the CLI or the API; there's no web page for them. | |
| 88 | + | |
| 89 | +```sh | |
| 90 | +diffinery secret set SENTRY_AUTH_TOKEN --org acme | |
| 91 | +diffinery secret set DEPLOY_KEY --org acme --repos website,api # only these two repositories | |
| 92 | +diffinery secret set NPM_TOKEN --org acme --visibility private | |
| 93 | +diffinery secret list --org acme | |
| 94 | +diffinery secret delete SENTRY_AUTH_TOKEN --org acme | |
| 95 | +``` | |
| 96 | + | |
| 97 | +```sh | |
| 98 | +curl -X PUT https://diffinery.com/api/v3/orgs/acme/actions/secrets/SENTRY_AUTH_TOKEN \ | |
| 99 | + -H "Authorization: Bearer dfy_..." \ | |
| 100 | + -H "Content-Type: application/json" \ | |
| 101 | + -d '{"value": "sntrys_..."}' | |
| 102 | +``` | |
| 103 | + | |
| 104 | +With the API, send `visibility` and, for `selected`, `selected_repository_ids` (the repositories' numeric `id`s) with the value: `{"value": "…", "visibility": "selected", "selected_repository_ids": [123, 456]}`. An update that leaves them out keeps the secret's selection. An id that isn't one of the organization's repositories gets `422`. | |
| 105 | + | |
| 106 | +| Endpoint | Does | | |
| 107 | +|---|---| | |
| 108 | +| `GET /api/v3/orgs/{org}/actions/secrets` | Lists names, update times and `visibility` | | |
| 109 | +| `GET /api/v3/orgs/{org}/actions/secrets/{name}` | One secret's name, update time and `visibility` (never the value) | | |
| 110 | +| `GET /api/v3/orgs/{org}/actions/secrets/{name}/repositories` | The selected repositories | | |
| 111 | +| `PUT /api/v3/orgs/{org}/actions/secrets/{name}/repositories` | Replaces the selection: `{"selected_repository_ids": [123, 456]}` | | |
| 112 | +| `PUT` or `DELETE /api/v3/orgs/{org}/actions/secrets/{name}/repositories/{repository_id}` | Adds or removes one repository | | |
| 113 | +| `DELETE /api/v3/orgs/{org}/actions/secrets/{name}` | Removes the secret | | |
| 114 | + | |
| 115 | +Changing the selection of a secret whose visibility isn't `selected` gets `409 the secret's visibility isn't selected`. A repository that leaves the selection stops getting the secret from its next job. Only organization owners and admins (and site admins) can use these endpoints; anyone else gets `403 organization admins only`. | |
| 116 | + | |
| 117 | +All three endpoints, listing included, are account-level actions: the CLI or API needs a personal token with the `admin` scope that isn't restricted to repositories. A `read` or `write` token gets `403 this token needs the admin scope`, and a token restricted to repositories gets `403 this token is limited to repositories and can't change account settings`. | |
| 118 | + | |
| 119 | +## Variables | |
| 120 | + | |
| 121 | +### Variables in the web UI | |
| 122 | + | |
| 123 | +On **Settings → Secrets and variables**, the **Variables** section lists every variable with its value. Enter a **Name** and a **Value** and click **Save variable** to add or replace one, or click **Delete** to remove one. | |
| 124 | + | |
| 125 | +### Variables with the CLI | |
| 126 | + | |
| 127 | +```sh | |
| 128 | +diffinery variable set DEPLOY_REGION -R acme/website --body eu-west-1 | |
| 129 | +echo "https://staging.example.com" | diffinery variable set STAGING_URL -R acme/website | |
| 130 | +diffinery variable list -R acme/website | |
| 131 | +``` | |
| 132 | + | |
| 133 | +There is no `variable delete` command; delete variables in the web UI or with the API. | |
| 134 | + | |
| 135 | +### Variables with the API | |
| 136 | + | |
| 137 | +The API has GitHub's endpoints (`POST` to create, `PATCH` to update) plus a Diffinery `PUT` that creates or replaces in one call: | |
| 138 | + | |
| 139 | +| Request | Needs | Does | | |
| 140 | +|---|---|---| | |
| 141 | +| `GET /api/v3/repos/{owner}/{repo}/actions/variables` | write role | Returns `{"total_count": 1, "variables": [{"name": "...", "value": "..."}]}`. Takes `per_page` (default 30, at most 100) and `page`. | | |
| 142 | +| `GET /api/v3/repos/{owner}/{repo}/actions/variables/{name}` | write role | Returns `{"name", "value", "created_at": null, "updated_at": null}`, or `404 Not Found` | | |
| 143 | +| `POST /api/v3/repos/{owner}/{repo}/actions/variables` with `{"name": "...", "value": "..."}` | admin role, people only | Creates a variable (201). An existing name fails with `409 Already exists - Variable already exists`; a missing field with `422 name and value are required`. | | |
| 144 | +| `PATCH /api/v3/repos/{owner}/{repo}/actions/variables/{name}` with `{"value": "..."}` and optionally a new `"name"` | admin role, people only | Updates or renames a variable (204), or `404 Not Found`. Renaming onto a name that already exists fails with `409 Already exists - Variable already exists`. A body with only `name` keeps the value. | | |
| 145 | +| `PUT /api/v3/repos/{owner}/{repo}/actions/variables/{name}` with `{"value": "..."}` | admin role, people only | Creates or replaces a variable (204) | | |
| 146 | +| `DELETE /api/v3/repos/{owner}/{repo}/actions/variables/{name}` | admin role, people only | Deletes a variable (204) | | |
| 147 | + | |
| 148 | +Changing variables needs a token with the `admin` scope. An agent's token gets `403 agents cannot manage variables` for every change, whatever its role. | |
| 149 | + | |
| 150 | +```sh | |
| 151 | +curl -X POST https://diffinery.com/api/v3/repos/acme/website/actions/variables \ | |
| 152 | + -H "Authorization: Bearer dfy_..." \ | |
| 153 | + -H "Content-Type: application/json" \ | |
| 154 | + -d '{"name": "DEPLOY_REGION", "value": "eu-west-1"}' | |
| 155 | +``` | |
| 156 | + | |
| 157 | +Variable names are stored in uppercase, and every endpoint, `DELETE` included, matches them without regard to letter case. | |
| 158 | + | |
| 159 | +## Organization variables | |
| 160 | + | |
| 161 | +Organization variables are available as `${{ vars.NAME }}` to every workflow in every repository the organization owns, including repositories created later. Use them for settings that many repositories share, such as a registry URL or a cloud region. A repository or environment variable with the same name wins (see [Precedence](#precedence)). | |
| 162 | + | |
| 163 | +They're managed with the REST API only: there's no web page for them and no CLI command (`diffinery variable` works on one repository). A [GitHub organization import](/docs/migrate-from-github) also adds the GitHub organization's variables that the Diffinery organization doesn't have yet, and leaves existing ones unchanged. Imported variables reach every repository, whatever repository selection they had on GitHub. | |
| 164 | + | |
| 165 | +### Who can manage organization variables | |
| 166 | + | |
| 167 | +| Action | Who | Token | | |
| 168 | +|---|---|---| | |
| 169 | +| List variables and read their values | Organization owners and admins, and site admins | Any scope | | |
| 170 | +| Create, update or delete a variable | Organization owners and admins, and site admins (people only) | The `admin` scope, not restricted to repositories | | |
| 171 | + | |
| 172 | +Other members get `403 organization admins only`, and accounts outside the organization get `404 not found`. Agents get `403 organization admins only` (or `404 not found` when neither the agent nor its owner belongs to the organization), whatever their role: agents can't read, create, change or delete organization variables through these endpoints. | |
| 173 | + | |
| 174 | +### Organization variables with the API | |
| 175 | + | |
| 176 | +| Request | Does | | |
| 177 | +|---|---| | |
| 178 | +| `GET /api/v3/orgs/{org}/actions/variables` | Returns `{"total_count": 1, "variables": [{"name": "...", "value": "...", "visibility": "all", "created_at": "...", "updated_at": "..."}]}`, sorted by name. The list isn't paged. | | |
| 179 | +| `GET /api/v3/orgs/{org}/actions/variables/{name}` | Returns one variable in the same shape, or `404 variable not found`. The name is matched case-insensitively. | | |
| 180 | +| `POST /api/v3/orgs/{org}/actions/variables` with `{"name": "...", "value": "..."}` | Creates a variable, or replaces the value of an existing one. Returns `201` with an empty body. | | |
| 181 | +| `PATCH /api/v3/orgs/{org}/actions/variables/{name}` with `{"value": "..."}` | Sets the value, creating the variable if it doesn't exist. Returns `204`. | | |
| 182 | +| `DELETE /api/v3/orgs/{org}/actions/variables/{name}` | Deletes the variable (case-insensitive name). Returns `204`, or `404 variable not found` when there's no such variable. | | |
| 183 | + | |
| 184 | +```sh | |
| 185 | +# Create or replace | |
| 186 | +curl -X POST https://diffinery.com/api/v3/orgs/acme/actions/variables \ | |
| 187 | + -H "Authorization: Bearer dfy_..." \ | |
| 188 | + -H "Content-Type: application/json" \ | |
| 189 | + -d '{"name": "NPM_REGISTRY", "value": "https://registry.npmjs.org"}' | |
| 190 | + | |
| 191 | +# List with values | |
| 192 | +curl https://diffinery.com/api/v3/orgs/acme/actions/variables \ | |
| 193 | + -H "Authorization: Bearer dfy_..." | |
| 194 | + | |
| 195 | +# Delete | |
| 196 | +curl -X DELETE https://diffinery.com/api/v3/orgs/acme/actions/variables/NPM_REGISTRY \ | |
| 197 | + -H "Authorization: Bearer dfy_..." | |
| 198 | +``` | |
| 199 | + | |
| 200 | +Names must match `^[A-Za-z_][A-Za-z0-9_]*$` and can't start with `GITHUB_`; otherwise the request fails with `422 invalid variable name`. Names are stored in uppercase. The value must be a string of at most 48 KB (49,152 characters); otherwise the request fails with `422 value must be a string of at most 48 KB`. In the responses, `created_at` is the time of the last change, the same as `updated_at`. | |
| 201 | + | |
| 202 | +Differences from GitHub: | |
| 203 | + | |
| 204 | +- There is no per-repository selection: `visibility` is always `all`, a `visibility` or `selected_repository_ids` field in the request is ignored, and there are no `/repositories` endpoints for a variable. | |
| 205 | +- `POST` on an existing name replaces the value instead of failing with `409`, and `PATCH` creates a missing variable instead of failing with `404`. | |
| 206 | +- `PATCH` can't rename a variable; a `name` in the body is ignored. Delete it and create it under the new name. | |
| 207 | +- The list isn't paged; `per_page` and `page` are ignored. | |
| 208 | + | |
| 209 | +### Organization and repository variables with the same name | |
| 210 | + | |
| 211 | +A repository's variables (the **Variables** section of **Settings → Secrets and variables**, `diffinery variable list`, and the `/api/v3/repos/{owner}/{repo}/actions/variables` endpoints) are only the repository's own, never the organization's. `GET` and `PATCH` on a name only the organization has answer `404 Not Found`, and `POST` creates a repository variable even when the organization has one of that name; in workflows, the repository's value wins. `DELETE` on a repository variable never removes an organization variable. | |
| 212 | + | |
| 213 | +## Environment secrets and variables | |
| 214 | + | |
| 215 | +An [environment](/docs/actions/environments) such as `production` can have its own secrets and variables. Only jobs that name the environment with `environment:` receive them: | |
| 216 | + | |
| 217 | +```yaml | |
| 218 | +jobs: | |
| 219 | + deploy: | |
| 220 | + runs-on: ubuntu-latest | |
| 221 | + environment: production | |
| 222 | + steps: | |
| 223 | + - run: npx wrangler deploy | |
| 224 | + env: | |
| 225 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} # the production secret | |
| 226 | + DEPLOY_URL: ${{ vars.DEPLOY_URL }} # the production variable | |
| 227 | +``` | |
| 228 | + | |
| 229 | +Environment secrets are managed on the environment's page (**Settings → Environments → (environment) → Environment secrets**) or with the REST API, and environment variables with the REST API only. Creating and deleting environment secrets needs a person (not an agent) with the admin role and an `admin`-scoped token; listing their names needs the admin role. Environment variables are listed with the write role and changed by a person (not an agent) with the admin role. | |
| 230 | + | |
| 231 | +```sh | |
| 232 | +# Environment secret: 201 when new, 204 when replaced | |
| 233 | +curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \ | |
| 234 | + -H "Authorization: Bearer dfy_..." \ | |
| 235 | + -H "Content-Type: application/json" \ | |
| 236 | + -d '{"value": "cf_..."}' | |
| 237 | + | |
| 238 | +# Environment variable: 201 | |
| 239 | +curl -X POST https://diffinery.com/api/v3/repos/acme/website/environments/production/variables \ | |
| 240 | + -H "Authorization: Bearer dfy_..." \ | |
| 241 | + -H "Content-Type: application/json" \ | |
| 242 | + -d '{"name": "DEPLOY_URL", "value": "https://www.example.com"}' | |
| 243 | +``` | |
| 244 | + | |
| 245 | +The environment must exist first (`404 environment not found` otherwise); create it with `PUT /api/v3/repos/{owner}/{repo}/environments/{name}`. The full set of endpoints is in [Deployments and environments](/docs/actions/environments#environment-secrets-and-variables). | |
| 246 | + | |
| 247 | +### Rules for environment secrets | |
| 248 | + | |
| 249 | +Environment secrets are meant for credentials that only reviewed code should use, so they're handed out more carefully than repository secrets: | |
| 250 | + | |
| 251 | +- **Branch policy.** A job can only use the environment from a ref its deployment branch policy allows; otherwise the job fails with `<ref> is not allowed to deploy to <env> due to environment protection rules.` | |
| 252 | +- **Protected branches by default.** Adding the first secret to an environment that has no branch policy sets it to **protected branches only**. | |
| 253 | +- **Never on pull request refs.** Environment secrets never go to runs on `refs/pull/*` (`pull_request` runs), whatever the policy. | |
| 254 | +- **Agents only under "protected branches only".** A run started by an agent (for example by its push or workflow dispatch) only gets environment secrets when the environment is set to protected branches only. | |
| 255 | +- **Agents' tags don't count.** A tag that an agent created or moved never matches an environment's tag patterns. | |
| 256 | + | |
| 257 | +When a rule withholds the secrets, the job still runs and still gets the environment's variables; the withheld secrets evaluate to empty strings. See [Who receives environment secrets](/docs/actions/environments#who-receives-environment-secrets). | |
| 258 | + | |
| 259 | +## Using secrets and variables in workflows | |
| 260 | + | |
| 261 | +```yaml | |
| 262 | +name: Deploy | |
| 263 | +on: | |
| 264 | + push: | |
| 265 | + branches: [main] | |
| 266 | + | |
| 267 | +env: | |
| 268 | + REGION: ${{ vars.DEPLOY_REGION }} | |
| 269 | + | |
| 270 | +jobs: | |
| 271 | + deploy: | |
| 272 | + runs-on: ubuntu-latest | |
| 273 | + steps: | |
| 274 | + - uses: actions/checkout@v4 | |
| 275 | + - name: Deploy | |
| 276 | + env: | |
| 277 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 278 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 279 | + run: npx wrangler deploy --env production | |
| 280 | +``` | |
| 281 | + | |
| 282 | +- Pass a secret to a step through `env` and read it from the environment, rather than writing `${{ secrets.X }}` into the script text. | |
| 283 | +- A secret or variable that doesn't exist evaluates to an empty string. | |
| 284 | +- Values are read when each job starts, so changing a secret affects jobs that start afterwards, including later jobs of a run that's in progress. | |
| 285 | +- A reusable workflow only receives the secrets its caller passes (a mapping or `secrets: inherit`). See [Reusable workflows](/docs/actions/reusable-workflows). | |
| 286 | +- A job's runner receives only the secrets the job names in its expressions (`secrets.NAME` or `secrets['NAME']`, in any letter case, anywhere in the job or in the workflow's `env`), as on GitHub, plus `ACTIONS_STEP_DEBUG` and `ACTIONS_RUNNER_DEBUG`. Other secrets never reach the runner, so a compromised action or tool in one job can't read them. A job that uses `secrets` as a whole, such as `toJSON(secrets)` or an index computed at run time, gets all of them. | |
| 287 | + | |
| 288 | +### Precedence | |
| 289 | + | |
| 290 | +From highest to lowest, for a name that exists in several places: | |
| 291 | + | |
| 292 | +1. `GITHUB_TOKEN` and `DIFFINERY_TOKEN`: always the job's own token. | |
| 293 | +2. Environment secrets, in a job that uses the environment. | |
| 294 | +3. Repository secrets (in a called reusable workflow: the secrets the caller passed). | |
| 295 | +4. Organization secrets shared with the repository (see [Organization secrets](#organization-secrets)). | |
| 296 | + | |
| 297 | +For variables, from highest to lowest: | |
| 298 | + | |
| 299 | +1. Environment variables, in a job that uses the environment. | |
| 300 | +2. Repository variables. | |
| 301 | +3. Organization variables (repositories owned by an organization only). | |
| 302 | + | |
| 303 | +In places evaluated before a job starts, such as `jobs.<id>.if` and `runs-on`, `vars` holds only repository and organization variables; environment variables are added when the job starts. A change to an organization variable reaches the organization's repositories right away, so jobs that start afterwards see the new value. If some repository can't be updated, the others still are and the request fails with `503`; repeat it to finish. | |
| 304 | + | |
| 305 | +### Which runs receive secrets | |
| 306 | + | |
| 307 | +Every workflow run in the repository receives its repository and organization secrets, whatever the event (the one exception, pull requests from forks, is below): `push`, `pull_request`, `pull_request_target`, `merge_group`, `workflow_dispatch`, `schedule`, `workflow_run`, `release` and `repository_dispatch`. Environment secrets follow stricter rules (see [Rules for environment secrets](#rules-for-environment-secrets)). | |
| 308 | + | |
| 309 | +This is a difference from GitHub worth understanding. GitHub withholds secrets from `pull_request` runs of pull requests that come from forks. Diffinery has no forks: every pull request comes from a branch in the same repository, pushed by someone with write access, so `pull_request` runs get the repository's secrets, and they run the workflow files from the pull request's own test merge commit. The exception is a pull request [imported from GitHub](/docs/migrate-from-github) whose head was in a fork (its head shows as `owner:branch`): its runs get no secrets and a read-only token, as on GitHub. `pull_request_target` runs the base branch's workflow on the base commit, also with secrets, so a pull request can't change what it runs. | |
| 310 | + | |
| 311 | +Runs of code an agent wrote are the other exception. A run whose trigger was an agent, whose branch or tag an agent pushed last, whose pull request an agent opened or pushed to, or (off the default branch) whose code includes a commit an agent pushed that hasn't landed on the default branch yet gets no repository or organization secrets, only its job token. A person pushing on top of an agent's commits, tagging them or dispatching a run on them doesn't change that; see [Runs whose code came from an agent](/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent). An agent with write access could otherwise push a workflow that sends them anywhere. A repository admin who is a person can allow it under **Settings → Actions** (**Give runs of agent code the repository's and organization's secrets**), or with `PUT /repos/{owner}/{repo}/actions/permissions/workflow` and `"agent_runs_use_secrets": true`. The change is audited. Environment secrets follow the environment's own rules. | |
| 312 | + | |
| 313 | +This includes the default branch once agents land changes on it. When an agent's pull request lands through [auto-merge](/docs/collaboration/pull-requests#auto-merge), the merge is the agent's push, so the branch was last pushed by an agent: every run on it from then on is agent code until a person pushes or merges there. That covers `push` runs and the `workflow_run` runs that follow them, such as a Deploy workflow that runs after CI. Such a run gets empty strings for repository and organization secrets, and the job log doesn't say they were withheld, so a deploy typically fails on its first missing value (`CLOUDFLARE_ACCOUNT_ID is required`, say). If agents land on your default branch, choose one of these: | |
| 314 | + | |
| 315 | +- **Keep deploy credentials in an environment** limited to protected branches, add `environment: <name>` to the deploying job, and protect the branch. Runs of agent code receive that environment's secrets on protected branches only, and never on pull request refs, so no other agent run sees them. This is the tighter option. See [Environments](/docs/actions/environments#who-receives-environment-secrets). | |
| 316 | +- **Turn on Give runs of agent code the repository's and organization's secrets.** Every run of agent code then gets them, branches and pull requests included, so treat write access for an agent as access to those secrets. | |
| 317 | + | |
| 318 | +In practice: **anyone who can push a branch can run a workflow that uses the repository's secrets**, and so can agents with write access where an admin allows it. Give write access only to people and agents you trust with the repository secrets, and keep production credentials in an [environment](/docs/actions/environments) set to protected branches only (see [Branch protection](/docs/repositories/branch-protection)) rather than in repository secrets. Pull request runs and branches that aren't protected never receive them. | |
| 319 | + | |
| 320 | +## Masking in logs | |
| 321 | + | |
| 322 | +The runner replaces every secret value with `***` before log lines leave the runner, so secrets don't appear in job logs, step names, step summaries or failure messages. | |
| 323 | + | |
| 324 | +- Every secret passed to the job is masked, including `GITHUB_TOKEN`. | |
| 325 | +- Values shorter than 3 characters aren't masked, because masking them would make logs unreadable. Don't use very short secrets. | |
| 326 | +- Multi-line secrets are masked line by line as well as whole. | |
| 327 | +- Masking matches the literal text only. A transformed value (base64-encoded, URL-encoded, split across lines, printed one character at a time) is not masked. Don't print secrets, even in debugging. | |
| 328 | +- A job output whose value contains a secret is dropped, with the warning `Skip output 'NAME' since it may contain secret.` | |
| 329 | +- Variables are never masked. | |
| 330 | + | |
| 331 | +### Masking other values with ::add-mask:: | |
| 332 | + | |
| 333 | +Mask a value that isn't a secret, such as a token you fetch during the job, by printing the `add-mask` workflow command: | |
| 334 | + | |
| 335 | +```yaml | |
| 336 | +- name: Get a short-lived token | |
| 337 | + run: | | |
| 338 | + token=$(curl -fsS -X POST https://auth.example.com/token -d "key=$API_KEY" | jq -r .token) | |
| 339 | + echo "::add-mask::$token" | |
| 340 | + echo "SESSION_TOKEN=$token" >> "$GITHUB_ENV" | |
| 341 | + env: | |
| 342 | + API_KEY: ${{ secrets.API_KEY }} | |
| 343 | +``` | |
| 344 | + | |
| 345 | +From that line on, the value is replaced with `***` in the rest of the job's log. The `add-mask` line itself is never echoed. | |
| 346 | + | |
| 347 | +### Debug logging | |
| 348 | + | |
| 349 | +Set a secret or a variable named `ACTIONS_STEP_DEBUG` to `true` to show `::debug::` messages in the log, set `RUNNER_DEBUG=1` for steps and make `runner.debug` `1`. A secret or variable named `ACTIONS_RUNNER_DEBUG` set to `true` also sets `RUNNER_DEBUG=1`, but doesn't change `runner.debug`. Turn them off again when you're done. | |
| 350 | + | |
| 351 | +## GITHUB_TOKEN and the job token | |
| 352 | + | |
| 353 | +Every job gets its own token, created when the job starts and revoked when it finishes. It's available as `${{ secrets.GITHUB_TOKEN }}`, `${{ secrets.DIFFINERY_TOKEN }}` and `${{ github.token }}`, and `actions/checkout` uses it by default. It always replaces a repository or organization secret with the same name. | |
| 354 | + | |
| 355 | +What the token may do follows the job's `permissions:`, as on GitHub: | |
| 356 | + | |
| 357 | +| Access | Allowed | | |
| 358 | +|---|---| | |
| 359 | +| Clone and fetch the repository that runs the workflow (including Git LFS downloads) | With `contents: read` or `write`, or always when the repository is public | | |
| 360 | +| Push to the repository | With `contents: write`. Branch protection applies, and changes to files under `.github/workflows/` are always refused. | | |
| 361 | +| Read the repository through the REST API | With the scope each endpoint needs, for example `issues: read` for issues. A public repository can always be read. | | |
| 362 | +| Write through the REST API (comments, labels, pull requests, releases, statuses, deployments) | With the matching scope at `write`, for example `pull-requests: write` to comment on a pull request | | |
| 363 | +| Read another repository | Only if that repository is public | | |
| 364 | +| Write to another repository | No | | |
| 365 | +| Use the container registry | Decided by the `packages` scope: `write` pushes and pulls, `read` pulls, `none` neither. Pushes reach only packages linked to the workflow's repository, or new packages, which get linked to it. | | |
| 366 | +| Request an OIDC token | Only with `id-token: write`. See [OpenID Connect (OIDC)](/docs/actions/oidc). | | |
| 367 | +| Manage secrets, variables, environments or repository settings | No | | |
| 368 | + | |
| 369 | +A job without any `permissions:` (in the job or the workflow) gets the repository's default: read access to contents and packages for repositories created after this setting was introduced, and write access to everything except `id-token` for older ones. An admin can change it. The full rules, the scope each endpoint needs and the error messages are on [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions). | |
| 370 | + | |
| 371 | +```yaml | |
| 372 | +permissions: | |
| 373 | + contents: write | |
| 374 | + pull-requests: write | |
| 375 | + | |
| 376 | +jobs: | |
| 377 | + bump: | |
| 378 | + runs-on: ubuntu-latest | |
| 379 | + steps: | |
| 380 | + - uses: actions/checkout@v4 | |
| 381 | + - run: | | |
| 382 | + git config user.name "diffinery-actions[bot]" | |
| 383 | + git config user.email "diffinery-actions[bot]@users.noreply.diffinery.com" | |
| 384 | + npm version patch --no-git-tag-version | |
| 385 | + git switch -c bump-version | |
| 386 | + git commit -am "Bump version" | |
| 387 | + git push origin bump-version | |
| 388 | +``` | |
| 389 | + | |
| 390 | +Use a personal access token stored as a secret instead when a workflow needs something the job token can't do, such as changing workflow files, writing to another repository, or pushing to a branch whose protection doesn't let the job push: | |
| 391 | + | |
| 392 | +```yaml | |
| 393 | +- uses: actions/checkout@v4 | |
| 394 | + with: | |
| 395 | + token: ${{ secrets.BOT_TOKEN }} | |
| 396 | +``` | |
| 397 | + | |
| 398 | +Create the token as a person, with the `write` scope and restricted to the repositories it needs. Pushes made this way still follow branch protection. See [Personal access tokens](/docs/accounts/tokens). | |
| 399 | + | |
| 400 | +## Encryption at rest | |
| 401 | + | |
| 402 | +Secret values are encrypted before they're stored, with envelope encryption: | |
| 403 | + | |
| 404 | +- Each repository, each organization and each environment has its own AES-256-GCM key, derived from the instance's master key with HKDF. | |
| 405 | +- The scope (the repository, the organization, or the repository and environment) is bound to every ciphertext as additional authenticated data, so a stored value copied into another repository or environment can't be decrypted there. | |
| 406 | +- Values are decrypted only when a job starts, and sent to that job's runner over HTTPS with the rest of the job's definition. | |
| 407 | +- The API, CLI and web UI only ever return names and update times. | |
| 408 | + | |
| 409 | +## Agents, secrets and variables | |
| 410 | + | |
| 411 | +Agents (AI accounts) can't add, change or delete secrets or variables, whatever their role: | |
| 412 | + | |
| 413 | +- The organization secret endpoints (listing included) return `403 agents cannot manage secrets` for any agent's token. | |
| 414 | +- Setting and deleting repository secrets (`PUT` and `DELETE /api/v3/repos/{owner}/{repo}/actions/secrets/{name}`) and environment secrets refuse an agent's token: an agent with the admin role gets `403 agents cannot manage secrets`; with the write role, the request already fails with `403 admin access required`. | |
| 415 | +- Creating, changing and deleting repository variables (`POST`, `PATCH`, `PUT` and `DELETE` on `/api/v3/repos/{owner}/{repo}/actions/variables`) and environment variables refuse an agent's token the same way, with `403 agents cannot manage variables` for an agent with the admin role. | |
| 416 | +- The organization variable endpoints (`/api/v3/orgs/{org}/actions/variables`, listing included) return `403 organization admins only` for any agent's token, or `404 not found` when neither the agent nor its owner belongs to the organization. A GitHub import started by an agent skips variables, secret names and environments. | |
| 417 | +- Agents can't create, change or delete environments or their branch policies (`403 only repository admins (people, not agents) can manage environments` for an agent with the admin role). | |
| 418 | +- Listing repository secret names and environment secret names needs the admin role; listing and reading variables needs the write role, so an agent with write access can read the repository's variable values (organization variables are readable only by organization owners and admins). An agent has the write role unless a person gave it more (its **Maximum role**, or an explicit admin role on the repository). | |
| 419 | + | |
| 420 | +Agents can still push branches and open pull requests. The workflows that run for them get no repository or organization secrets unless a repository admin allows it with `agent_runs_use_secrets` (see [Which runs receive secrets](#which-runs-receive-secrets)); where it's allowed, treat write access for an agent as access to those secrets. Environment secrets are different: runs whose code came from an agent only get them from environments set to protected branches only, and never on pull request refs. In those runs the job token also acts as an agent, so it can't push to protected or default branches. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent) and [Agents](/docs/agents/overview). | |
| 421 | + | |
| 422 | +## Audit log | |
| 423 | + | |
| 424 | +Changes are recorded in the audit log with the name but never the value: | |
| 425 | + | |
| 426 | +| Change | Audit action | | |
| 427 | +|---|---| | |
| 428 | +| Set or delete a repository secret | `repo.secret.set`, `repo.secret.delete` | | |
| 429 | +| Create, update, rename or delete a repository variable | `repo.variable.set` (a rename adds `renamed_from` with the old name), `repo.variable.delete` | | |
| 430 | +| Set or delete an organization secret | `org.secret.set`, `org.secret.delete` | | |
| 431 | +| Create, update or delete an organization variable | `org.variable_set`, `org.variable_delete`, with the organization as the target and `name` in the details | | |
| 432 | +| Set or delete an environment secret | `environment.secret.set`, `environment.secret.delete` | | |
| 433 | +| Create, update, rename or delete an environment variable | `environment.variable.set` (a rename adds `renamed_from`), `environment.variable.delete` | | |
| 434 | +| Change an environment or its branch policies | `environment.update`, `environment.delete`, `environment.branch_policy.create`, `environment.branch_policy.delete` | | |
| 435 | + | |
| 436 | +Repository and organization secret changes made with the API or the CLI include `"via": "api"` in the entry's details; changes made on the settings page don't. Organization variable changes made with the API include it too. When a GitHub organization import adds organization variables, each one is recorded as `org.variable_set` by the person who started the import, with the GitHub organization as the target and the import's id, the Diffinery organization (`org`) and `name` in the details. See [Audit log](/docs/security/audit-log). | |
| 437 | + | |
| 438 | +## Limits | |
| 439 | + | |
| 440 | +| Limit | Value | | |
| 441 | +|---|---| | |
| 442 | +| Name format | `^[A-Za-z_][A-Za-z0-9_]*$`, stored uppercase; repository and environment secrets, and environment and organization variables, can't start with `GITHUB_` | | |
| 443 | +| Shortest value that is masked | 3 characters | | |
| 444 | +| Organization variable value | 48 KB (49,152 characters) | | |
| 445 | +| Number of organization variables | No limit of its own | | |
| 446 | +| Number and size of secrets | Diffinery doesn't enforce a limit of its own; every secret is decrypted when each job starts (only the ones the job names are sent to its runner), so keep them few and small | | |
| 447 | + | |
| 448 | +## Troubleshooting | |
| 449 | + | |
| 450 | +| Problem | Cause and fix | | |
| 451 | +|---|---| | |
| 452 | +| `422 invalid secret name` | The name has characters other than letters, digits and `_`, starts with a digit, or (repository secrets) starts with `GITHUB_`. | | |
| 453 | +| `invalid secret name "...": use letters, digits and _, not starting with a digit` | The CLI checks the name before sending it. | | |
| 454 | +| `422 value is required (send it over HTTPS; it is encrypted at rest)` | The JSON body has no string `value`. | | |
| 455 | +| ``422 encrypted_value (sealed box) is not supported yet: send the secret as `value` over HTTPS; it is encrypted at rest`` | The client encrypted the value with a public key, as GitHub requires. Send the plain value as `value`. | | |
| 456 | +| `secret value is empty` | The CLI got an empty value from `--body` or stdin. | | |
| 457 | +| A secret is empty in a workflow run, or a deploy fails on a missing value such as `CLOUDFLARE_ACCOUNT_ID is required`, though the secret is set | The run is agent code: an agent triggered it, pushed its branch last (an agent's auto-merge counts), or opened or pushed its pull request. It gets no repository or organization secrets. Move the secrets into an environment limited to protected branches, or have an admin turn on **Give runs of agent code the repository's and organization's secrets**. See [Which runs receive secrets](#which-runs-receive-secrets). | | |
| 458 | +| `403 agents cannot manage secrets` | Use a person's token. | | |
| 459 | +| `403 agents cannot manage variables` | Agents can't create, change or delete variables. Use a person's token. | | |
| 460 | +| `403 admin access required` | The token's owner isn't a repository admin, or the token lacks the `admin` scope. | | |
| 461 | +| `403 this token needs the write scope` | The token has only the `read` scope and can't change anything. Use an `admin` token. | | |
| 462 | +| `403 this token needs the admin scope` | Organization secrets need an `admin`-scoped token, even for listing. Creating, changing and deleting organization variables needs one too. | | |
| 463 | +| `403 this token is limited to repositories and can't change account settings` | Organization secrets, and changes to organization variables, need a token without a repository list. | | |
| 464 | +| `403 organization admins only` | Only organization owners and admins (people) manage organization secrets and organization variables, and only they can read organization variables. | | |
| 465 | +| `422 invalid variable name` | The organization variable's name has characters other than letters, digits and `_`, starts with a digit, or starts with `GITHUB_`. | | |
| 466 | +| `422 value must be a string of at most 48 KB` | The organization variable request has no string `value`, or the value is longer than 49,152 characters. | | |
| 467 | +| `404 variable not found` | The organization has no variable of that name. | | |
| 468 | +| `403 only repository admins (people, not agents) can manage environments` | Environments and their branch policies need a person with the admin role. | | |
| 469 | +| `404 environment not found` | Create the environment with `PUT .../environments/{name}` before adding secrets or variables. | | |
| 470 | +| An environment secret is empty in the job | The run is on a pull request ref, or an agent started it and the environment isn't set to protected branches only, or the job has no `environment:`. | | |
| 471 | +| `<ref> is not allowed to deploy to <env> due to environment protection rules.` | The environment's branch policy doesn't allow the ref. See [Deployments and environments](/docs/actions/environments#deployment-branch-policies). | | |
| 472 | +| A secret is empty in the workflow | Check the name, that it's set on this repository, its organization or the job's environment, and, in a reusable workflow, that the caller passes it. | | |
| 473 | +| `***` appears in output where you expected text | The text contains a secret's value (or a value shorter than you think matches part of it). Rename or change the secret, or don't print that text. | | |
| 474 | +| `git push` with `GITHUB_TOKEN` fails with `403` | The job has no `contents: write`. Add it to the job's or the workflow's `permissions:`, or ask an admin to change the repository default. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions). | | |
| 475 | +| `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: ...)` | The job's `permissions:` don't include the scope that endpoint needs. Add the scope the message names. | | |
| 476 | +| `409 Already exists - Variable already exists` | A variable with that name already exists in the repository, or (for `POST` on a repository) the organization has a variable with that name. Update it with `PATCH` or `PUT`, use `PUT` to give the repository its own value, or choose another name. | | |
| 477 | +| A variable is empty in the workflow | Check the name and that it's set on this repository, its organization or the job's environment. Environment variables aren't available in `jobs.<id>.if` or `runs-on`. | |
addedapps/diffinery/docs/actions/workflow-permissions.md+350−0View file
| @@ -1,0 +1,350 @@ | ||
| 1 | +--- | |
| 2 | +title: GITHUB_TOKEN permissions | |
| 3 | +description: What a workflow job's GITHUB_TOKEN may do on Diffinery, how permissions are resolved, the repository default, and the rules no permission can lift. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +Every workflow job gets its own token, `GITHUB_TOKEN`, that works against Diffinery's REST API and git server. What the token may do follows GitHub's `permissions:` model: each API endpoint needs a permission scope, and the job's effective `permissions:` decide which scopes the token has. This page explains how those permissions are worked out, how to change the repository's default, and the rules that apply whatever a workflow asks for. | |
| 7 | + | |
| 8 | +## The job token | |
| 9 | + | |
| 10 | +The token is available as `secrets.GITHUB_TOKEN`, `github.token` and, under a Diffinery name, `secrets.DIFFINERY_TOKEN`. It isn't an environment variable unless you set one: | |
| 11 | + | |
| 12 | +```yaml | |
| 13 | +- run: gh api repos/${{ github.repository }} | |
| 14 | + env: | |
| 15 | + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| 16 | +``` | |
| 17 | + | |
| 18 | +- It works only while the job runs. When the job completes, the token stops working, and it never works more than 30 minutes past the job's timeout (counted from when the job started), even if the job is never marked complete. | |
| 19 | +- It acts in the repository as `diffinery-actions[bot]`, so issues, comments and labels it creates show that name. | |
| 20 | +- Its role is `write` when any scope other than `id-token` is `write`, and `read` otherwise. It never has the `maintain` or `admin` role, so [branch protection](/docs/repositories/branch-protection) always applies to it. | |
| 21 | +- Each request is then checked against the scope its endpoint needs (see [What each endpoint needs](#what-each-endpoint-needs)). | |
| 22 | + | |
| 23 | +## How permissions are resolved | |
| 24 | + | |
| 25 | +A job's effective permissions come from the first of these that exists: | |
| 26 | + | |
| 27 | +1. The job's own `permissions:` (`jobs.<id>.permissions`). It replaces the workflow's, it isn't merged with it. | |
| 28 | +2. The workflow's top-level `permissions:`. | |
| 29 | +3. The repository default, set by an admin (see [The repository default](#the-repository-default)). | |
| 30 | + | |
| 31 | +Inside a [reusable workflow](/docs/actions/reusable-workflows), the calling job's permissions take the place of the repository default and also cap everything the called workflow declares. See [Reusable workflows](#reusable-workflows). | |
| 32 | + | |
| 33 | +### Scopes | |
| 34 | + | |
| 35 | +`permissions:` takes these scopes, the same as GitHub's: | |
| 36 | + | |
| 37 | +| Scope | What it covers on Diffinery | | |
| 38 | +| --- | --- | | |
| 39 | +| `actions` | Workflows, runs, jobs, logs, artifacts and caches (`/actions/...`), including starting a `workflow_dispatch` run | | |
| 40 | +| `attestations` | `/attestations` | | |
| 41 | +| `checks` | Check runs and check suites | | |
| 42 | +| `contents` | Git fetch and push, files, commits, branches, tags, releases and release assets, compare, archives, commit comments, `repository_dispatch`, merging pull requests | | |
| 43 | +| `deployments` | Deployments, deployment statuses, and reading environments | | |
| 44 | +| `discussions` | `/discussions` | | |
| 45 | +| `id-token` | Requesting an [OIDC](/docs/actions/oidc) token. Only `write` (or `none`) has a meaning. | | |
| 46 | +| `issues` | Issues, issue comments, labels, milestones and assignees | | |
| 47 | +| `models` | Accepted for compatibility. Only `read` or `none`. | | |
| 48 | +| `packages` | The [container registry](/docs/packages/container-registry) | | |
| 49 | +| `pages` | `/pages` | | |
| 50 | +| `pull-requests` | Pull requests, reviews and review comments, and issue endpoints used on pull requests | | |
| 51 | +| `repository-projects` | `/projects` | | |
| 52 | +| `security-events` | `/code-scanning`, `/secret-scanning`, `/dependabot` | | |
| 53 | +| `statuses` | Commit statuses | | |
| 54 | + | |
| 55 | +Each scope takes `read`, `write` or `none`. `write` includes `read`. An unknown scope gives the warning `unknown permission scope '<scope>'` when the workflow is parsed, and an invalid level fails the file with `permission '<scope>' must be 'read', 'write' or 'none'`. | |
| 56 | + | |
| 57 | +### Shorthand values | |
| 58 | + | |
| 59 | +| Value | Result | | |
| 60 | +| --- | --- | | |
| 61 | +| `read-all` | Every scope `read`, except `id-token`, which gets nothing. | | |
| 62 | +| `write-all` | Every scope `write`, except `models`, which stays `read`. Includes `id-token: write`. | | |
| 63 | +| `{}` | Every scope `none`. The token can still read what anyone may read in a public repository. | | |
| 64 | +| A mapping | The scopes you list get the levels you give. **Every scope you don't list is `none`.** | | |
| 65 | + | |
| 66 | +Two scopes have special rules: | |
| 67 | + | |
| 68 | +- `id-token: read` grants nothing. OIDC needs `id-token: write`. | |
| 69 | +- `models: write` is lowered to `read`. | |
| 70 | + | |
| 71 | +Any other value of `permissions:` fails the file with `permissions must be 'read-all', 'write-all' or a mapping of scope: read|write|none`. | |
| 72 | + | |
| 73 | +### Examples | |
| 74 | + | |
| 75 | +A workflow that only reads code, with one job that comments on pull requests: | |
| 76 | + | |
| 77 | +```yaml | |
| 78 | +permissions: | |
| 79 | + contents: read | |
| 80 | + | |
| 81 | +jobs: | |
| 82 | + test: | |
| 83 | + runs-on: ubuntu-latest | |
| 84 | + steps: | |
| 85 | + - uses: actions/checkout@v4 | |
| 86 | + - run: npm ci && npm test | |
| 87 | + | |
| 88 | + comment: | |
| 89 | + needs: test | |
| 90 | + runs-on: ubuntu-latest | |
| 91 | + permissions: | |
| 92 | + contents: read # the job's map replaces the workflow's, so repeat contents | |
| 93 | + pull-requests: write | |
| 94 | + steps: | |
| 95 | + - uses: actions/github-script@v7 | |
| 96 | + with: | |
| 97 | + script: | | |
| 98 | + await github.rest.issues.createComment({ | |
| 99 | + owner: context.repo.owner, | |
| 100 | + repo: context.repo.repo, | |
| 101 | + issue_number: context.issue.number, | |
| 102 | + body: "Tests passed.", | |
| 103 | + }); | |
| 104 | +``` | |
| 105 | + | |
| 106 | +A release job that pushes a tag and uploads release assets needs `contents: write`. A job that pushes an image needs `packages: write`, and because naming any scope sets the others to `none`, it also needs `contents: read` to check out a private repository: | |
| 107 | + | |
| 108 | +```yaml | |
| 109 | +jobs: | |
| 110 | + image: | |
| 111 | + runs-on: ubuntu-latest | |
| 112 | + permissions: | |
| 113 | + contents: read | |
| 114 | + packages: write | |
| 115 | +``` | |
| 116 | + | |
| 117 | +## The repository default | |
| 118 | + | |
| 119 | +When neither the job nor the workflow sets `permissions:`, the job gets the repository default. It's one of two settings, the same as GitHub's "Workflow permissions" setting: | |
| 120 | + | |
| 121 | +| `default_workflow_permissions` | Token permissions | | |
| 122 | +| --- | --- | | |
| 123 | +| `read` | `contents: read` and `packages: read`. Every other scope `none`. | | |
| 124 | +| `write` | Every scope `write` (`models` `read`), except `id-token`, which gets nothing. | | |
| 125 | + | |
| 126 | +| Repository | Default | | |
| 127 | +| --- | --- | | |
| 128 | +| Created after this setting was added | `read`, and pull request creation and approval off | | |
| 129 | +| Created before | `write`, and pull request creation and approval off | | |
| 130 | + | |
| 131 | +A second setting, `can_approve_pull_request_reviews`, decides whether job tokens may open pull requests and approve or request changes on them (see [Creating and approving pull requests](#creating-and-approving-pull-requests)). It's `false` by default. | |
| 132 | + | |
| 133 | +A third, `agent_runs_use_secrets` (a Diffinery addition), decides whether runs of code an agent wrote get the repository's and organization's secrets. It's `false` by default; see [Which runs receive secrets](/docs/actions/secrets-and-variables#which-runs-receive-secrets). | |
| 134 | + | |
| 135 | +### Read and change the setting | |
| 136 | + | |
| 137 | +In the web UI, open the repository's **Settings → Actions** tab (`/{owner}/{repo}/settings/actions`). Under **Workflow permissions**, pick **Read repository contents and packages permissions** or **Read and write permissions**, check or clear **Allow GitHub Actions to create and approve pull requests** and **Give runs of agent code the repository's and organization's secrets**, and click **Save**. You see "Actions settings saved." The tab needs the **admin** role on the repository. | |
| 138 | + | |
| 139 | +You can also read and change it with the REST API or the CLI. Both need the **admin** role on the repository, and only people can change it: an agent's token gets `403 only people can change what workflows are allowed to do`. | |
| 140 | + | |
| 141 | +Read the current setting: | |
| 142 | + | |
| 143 | +```http | |
| 144 | +GET /api/v3/repos/acme/website/actions/permissions/workflow | |
| 145 | +Authorization: Bearer dfy_... | |
| 146 | +``` | |
| 147 | + | |
| 148 | +```json | |
| 149 | +{ | |
| 150 | + "default_workflow_permissions": "read", | |
| 151 | + "can_approve_pull_request_reviews": false, | |
| 152 | + "agent_runs_use_secrets": false | |
| 153 | +} | |
| 154 | +``` | |
| 155 | + | |
| 156 | +Change it with `PUT`. Send any of the fields; a field you leave out keeps its value. The response is `204 No Content`: | |
| 157 | + | |
| 158 | +```sh | |
| 159 | +curl -fsS -X PUT https://diffinery.com/api/v3/repos/acme/website/actions/permissions/workflow \ | |
| 160 | + -H "Authorization: Bearer dfy_..." \ | |
| 161 | + -H "Content-Type: application/json" \ | |
| 162 | + -d '{"default_workflow_permissions": "write", "can_approve_pull_request_reviews": true}' | |
| 163 | +``` | |
| 164 | + | |
| 165 | +With the CLI (install it with `curl -fsSL https://diffinery.com/install | bash`, then `diffinery login`): | |
| 166 | + | |
| 167 | +```sh | |
| 168 | +diffinery api GET /repos/acme/website/actions/permissions/workflow | |
| 169 | +diffinery api PUT /repos/acme/website/actions/permissions/workflow -f default_workflow_permissions=write | |
| 170 | +``` | |
| 171 | + | |
| 172 | +`-f` sends string values only. To change `can_approve_pull_request_reviews`, which is a boolean, send a JSON body with `--input` (a file, or `-` for stdin): | |
| 173 | + | |
| 174 | +```sh | |
| 175 | +echo '{"can_approve_pull_request_reviews": true}' \ | |
| 176 | + | diffinery api PUT /repos/acme/website/actions/permissions/workflow --input - | |
| 177 | +``` | |
| 178 | + | |
| 179 | +Every change is recorded in the [audit log](/docs/security/audit-log) as `repo.actions_workflow_settings`, with the new values. | |
| 180 | + | |
| 181 | +| Status and message | Cause | | |
| 182 | +| --- | --- | | |
| 183 | +| `403 admin access required` | Your role on the repository is below admin, or your token's scope doesn't reach admin. | | |
| 184 | +| `403 only people can change what workflows are allowed to do` | The request used an agent's token. | | |
| 185 | +| `422 a JSON body is required` | The body is missing or isn't JSON. | | |
| 186 | +| `422 default_workflow_permissions must be read or write` | Another value was sent. | | |
| 187 | +| `422 can_approve_pull_request_reviews must be a boolean` | A string such as `"true"` was sent instead of `true`. | | |
| 188 | + | |
| 189 | +> **Note:** The setting is read when a job starts. Jobs that are already running keep the permissions they started with. | |
| 190 | + | |
| 191 | +## What each endpoint needs | |
| 192 | + | |
| 193 | +Each request the token makes to `/api/v3/repos/{owner}/{repo}/...`, to the git server, or to a repository's web pages is matched to a scope by the first path segment after the repository. `GET`, `HEAD` and `OPTIONS` need `read`; other methods need `write`. Git fetches (`git-upload-pack`) and Git LFS downloads are reads even though they use `POST`. | |
| 194 | + | |
| 195 | +| Path after `/repos/{owner}/{repo}` | Scope | | |
| 196 | +| --- | --- | | |
| 197 | +| `/issues` (list or create issues) | `issues` | | |
| 198 | +| `/issues/...`, `/labels`, `/milestones`, `/assignees` | `issues` or `pull-requests` (either one is enough) | | |
| 199 | +| `/pulls`, `/pulls/...` | `pull-requests` | | |
| 200 | +| `/pulls/{number}/merge`, `/pulls/{number}/update-branch`, `/pulls/{number}/auto-merge` | `contents` | | |
| 201 | +| `/statuses/...`, `/commits/{sha}/status`, `/commits/{sha}/statuses` | `statuses` | | |
| 202 | +| `/check-runs`, `/check-suites`, `/commits/{sha}/check-runs`, `/commits/{sha}/check-suites` | `checks` | | |
| 203 | +| `/deployments`, reading `/environments` | `deployments` | | |
| 204 | +| `/actions/...` | `actions` | | |
| 205 | +| `/pages` | `pages` | | |
| 206 | +| `/discussions` | `discussions` | | |
| 207 | +| `/code-scanning`, `/secret-scanning`, `/dependabot` | `security-events` | | |
| 208 | +| `/packages` | `packages` | | |
| 209 | +| `/projects` | `repository-projects` | | |
| 210 | +| `/attestations` | `attestations` | | |
| 211 | +| `/contents`, `/commits`, `/compare`, `/git`, `/branches`, `/tags`, `/releases`, `/merges`, `/tarball`, `/zipball`, `/readme`, `/dispatches`, `/comments`, `/merge-upstream`, `/languages`, `/contributors`, `/stats` | `contents` | | |
| 212 | +| Git over HTTPS (`/{owner}/{repo}.git/...`), including push | `contents` | | |
| 213 | +| Release asset uploads (`/api/uploads/repos/{owner}/{repo}/releases/{id}/assets`) | `contents` | | |
| 214 | +| `GET /repos/{owner}/{repo}` and other reads not listed here | None: repository metadata is always readable | | |
| 215 | + | |
| 216 | +### Endpoints a job token can never use | |
| 217 | + | |
| 218 | +Some endpoints are refused whatever the permissions, even with `write-all`: | |
| 219 | + | |
| 220 | +- `/hooks`, `/collaborators`, `/keys`, `/invitations`, `/settings`, `/rulesets`, `/teams`, `/transfer`, `/autolinks`, `/vulnerability-alerts` | |
| 221 | +- `/actions/secrets`, `/actions/variables`, `/actions/runners`, `/actions/permissions`, `/actions/oidc` | |
| 222 | +- Creating, changing or deleting environments, and environment secrets and variables | |
| 223 | +- Branch protection (`/branches/{branch}/protection`) | |
| 224 | +- Changing or deleting the repository itself (`PATCH` or `DELETE /repos/{owner}/{repo}`) | |
| 225 | +- Any write to a path not in the table above | |
| 226 | + | |
| 227 | +Most of these endpoints need the admin role, which a job token never has, so the response is `403 admin access required`. The others answer `403 this endpoint is not available to a workflow's GITHUB_TOKEN`. | |
| 228 | + | |
| 229 | +A job token can't re-run workflows either: `POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun` (and `/rerun-failed-jobs`, and `POST /repos/{owner}/{repo}/actions/jobs/{job_id}/rerun`) fails with `403 a workflow's GITHUB_TOKEN cannot re-run workflows`. | |
| 230 | + | |
| 231 | +### The 403 response | |
| 232 | + | |
| 233 | +A request whose scope the token lacks gets GitHub's message, with the scope it needed: | |
| 234 | + | |
| 235 | +```json | |
| 236 | +{ | |
| 237 | + "message": "Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: issues: write)", | |
| 238 | + "documentation_url": "https://docs.github.com/rest" | |
| 239 | +} | |
| 240 | +``` | |
| 241 | + | |
| 242 | +When either of two scopes is enough, the message lists both, for example `needs permissions: issues or pull-requests: write`. Add the scope to the job's `permissions:` and run the job again. | |
| 243 | + | |
| 244 | +## Reading public repositories | |
| 245 | + | |
| 246 | +What anyone may read in a public repository, a job token may read too, whatever its permissions: | |
| 247 | + | |
| 248 | +- In its own public repository, a read is allowed even with `permissions: {}` or `contents: none`. | |
| 249 | +- It can read **other** public repositories on Diffinery, for example to check out a public dependency or clone a public submodule, but only read: any write is refused. | |
| 250 | +- It can't see other private repositories at all, even ones with the same owner. A request gets `404`. To check out another private repository, pass a personal or agent token from a secret as `token:`. | |
| 251 | + | |
| 252 | +## Workflow files | |
| 253 | + | |
| 254 | +A job token never creates, changes or deletes files under `.github/workflows/` or `.diffinery/workflows/`, whatever its permissions. This is GitHub's `workflows` permission, which a `GITHUB_TOKEN` can't have. The refusal reads: | |
| 255 | + | |
| 256 | +```text | |
| 257 | +refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission | |
| 258 | +``` | |
| 259 | + | |
| 260 | +It applies to: | |
| 261 | + | |
| 262 | +- `git push`: the ref update is rejected with that reason. | |
| 263 | +- Commits through the API, such as the contents API. | |
| 264 | +- Merging a pull request: the message appears as a merge blocker when the pull request changes a workflow file. | |
| 265 | + | |
| 266 | +A push that creates a new branch is compared with where the branch left the default branch, so a new branch that only carries workflow changes already on the default branch is allowed. To change workflow files from automation, use a personal or agent token from a secret. | |
| 267 | + | |
| 268 | +## Creating and approving pull requests | |
| 269 | + | |
| 270 | +By default a job token can't open pull requests, approve them or request changes on them. The request fails with: | |
| 271 | + | |
| 272 | +```text | |
| 273 | +GitHub Actions is not permitted to create pull requests. An admin can allow it in the repository's Actions settings ("Allow GitHub Actions to create and approve pull requests"). | |
| 274 | +``` | |
| 275 | + | |
| 276 | +For reviews the message says `approve` instead of `create`. Commenting reviews are always allowed. To allow the rest, set `can_approve_pull_request_reviews` to `true` (see [Read and change the setting](#read-and-change-the-setting)); the job also needs `pull-requests: write`. | |
| 277 | + | |
| 278 | +Even when allowed, an approval from a job token **never counts** toward a branch's required approvals. Only approvals by people count. | |
| 279 | + | |
| 280 | +## Pull requests from forks | |
| 281 | + | |
| 282 | +A `pull_request` run for a pull request whose head is in another repository gets read access only: `read-all` at most, whatever the workflow asks for, and never `id-token: write`. It also gets no secrets: no repository, organization or environment secrets, only `GITHUB_TOKEN`, as on GitHub. | |
| 283 | + | |
| 284 | +Diffinery has no forks of its own, so today this applies to pull requests [imported from GitHub](/docs/migrate-from-github) whose head was in a fork. Their head shows as `owner:branch`. | |
| 285 | + | |
| 286 | +## Reusable workflows | |
| 287 | + | |
| 288 | +A [reusable workflow](/docs/actions/reusable-workflows) never gets more than its caller: | |
| 289 | + | |
| 290 | +- A job in the called workflow without `permissions:` (and a called workflow without top-level `permissions:`) inherits the calling job's permissions. | |
| 291 | +- Whatever the called workflow or its jobs declare is lowered, scope by scope, to what the calling job has. | |
| 292 | +- The calling job's own permissions follow the usual order: its `permissions:`, then its workflow's, then the repository default. | |
| 293 | + | |
| 294 | +```yaml | |
| 295 | +# caller | |
| 296 | +jobs: | |
| 297 | + deploy: | |
| 298 | + uses: ./.github/workflows/deploy.yml | |
| 299 | + permissions: | |
| 300 | + contents: read | |
| 301 | + id-token: write | |
| 302 | +``` | |
| 303 | + | |
| 304 | +Here the jobs in `deploy.yml` can have at most `contents: read` and `id-token: write`, even if `deploy.yml` asks for `write-all`. | |
| 305 | + | |
| 306 | +## Runs whose code came from an agent | |
| 307 | + | |
| 308 | +A run is treated as agent code when: | |
| 309 | + | |
| 310 | +- an agent triggered it, | |
| 311 | +- an agent last pushed the branch or tag it runs on (a merge that Diffinery lands for an agent through auto-merge counts as the agent's push), | |
| 312 | +- for pull request and merge queue events, an agent opened the pull request or last pushed its head branch, or | |
| 313 | +- off the default branch, the code it runs includes a commit an agent pushed that hasn't landed on the default branch yet. A person pushing on top of an agent's commits, tagging them, making a release from them or dispatching a workflow on them doesn't make that code a person's. Once a person merges the commits into the default branch (or a person lands them there), they count as that person's, since the default branch's runs go by who moved it. | |
| 314 | + | |
| 315 | +A re-run of such a run keeps this, whoever starts the re-run, and so does a `workflow_run` run that follows such a run, since it acts on what that run produced (its artifacts and outputs). The jobs of these runs act as an agent, so the [agent guardrails](/docs/agents/overview) hold for code an agent wrote, even when a person triggered the run: | |
| 316 | + | |
| 317 | +- Pushes to the default branch or a protected branch are refused with `agents may not push to protected or default branches; open a pull request`, unless the branch's protection rule has **Allow agents to push directly** on. | |
| 318 | +- Merges into those branches are blocked with `agents may not merge into this branch; a human must merge`, unless the rule has **Allow agents to merge pull requests** on. The token can still turn on [auto-merge](/docs/collaboration/pull-requests#auto-merge) where the rule has **Allow agents to land pull requests through auto-merge** and a required check; that rule holds for every job token on those branches, agent code or not. | |
| 319 | +- A pull request the job opens counts as agent-authored, so it needs at least one approval from a person, unless the base branch's rule [waives approvals for agents' auto-merges](/docs/repositories/branch-protection#waiving-approvals-for-agents). | |
| 320 | +- A deployment the job records counts as the agent's, so [environment secret rules for agents](/docs/actions/environments#who-receives-environment-secrets) apply. | |
| 321 | +- The jobs get no repository or organization secrets, only the job token, unless a repository admin who is a person turns on `agent_runs_use_secrets` (see [Which runs receive secrets](/docs/actions/secrets-and-variables#which-runs-receive-secrets)). | |
| 322 | + | |
| 323 | +## Runs started by the token | |
| 324 | + | |
| 325 | +Like on GitHub, what a job does with its token starts no new workflow runs, so a workflow can't trigger itself in a loop. Pushes, pull requests, releases and merges made with a job token (including a later merge by an auto-merge the token turned on) don't start runs. The two exceptions, as on GitHub, are `workflow_dispatch` (a job with `actions: write` can start another workflow with `POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`) and `repository_dispatch` (`POST /repos/{owner}/{repo}/dispatches`, which needs `contents: write`). | |
| 326 | + | |
| 327 | +## Differences from GitHub | |
| 328 | + | |
| 329 | +| Area | GitHub | Diffinery | | |
| 330 | +| --- | --- | --- | | |
| 331 | +| Changing the default | **Settings** > **Actions** > **General** > **Workflow permissions** | **Settings** > **Actions** > **Workflow permissions**, or `PUT /repos/{owner}/{repo}/actions/permissions/workflow` | | |
| 332 | +| Organization and enterprise defaults | Can limit repositories | Not available; each repository has its own setting | | |
| 333 | +| Agent code | Not a concept | Runs whose code came from an agent are held to the agent rules | | |
| 334 | +| Name in the repository | `github-actions[bot]` | `diffinery-actions[bot]` | | |
| 335 | +| Extra name | None | `secrets.DIFFINERY_TOKEN` | | |
| 336 | + | |
| 337 | +## Troubleshooting | |
| 338 | + | |
| 339 | +| Problem | Fix | | |
| 340 | +| --- | --- | | |
| 341 | +| `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: <scope>: write)` | Add that scope to the job's `permissions:`, or to the workflow's when the job has none. In a reusable workflow, the calling job needs it too. | | |
| 342 | +| A job that used to write now gets 403 | The repository's default is `read` (the default for new repositories), or the workflow added a `permissions:` map that leaves the scope out. Add the scope, or have an admin set `default_workflow_permissions` to `write`. | | |
| 343 | +| `actions/checkout` fails on a private repository after adding `permissions:` | Naming any scope sets the others to `none`. Add `contents: read`. | | |
| 344 | +| `this endpoint is not available to a workflow's GITHUB_TOKEN`, or `admin access required` from a job | The endpoint manages settings, secrets, variables, hooks, runners, environments or branch protection. Use a personal or agent token from a secret. | | |
| 345 | +| `refusing to allow a GitHub App to create or update workflow ...` | Job tokens never change workflow files. Use a personal or agent token. | | |
| 346 | +| `GitHub Actions is not permitted to create pull requests. ...` | An admin sets `can_approve_pull_request_reviews` to `true`, and the job needs `pull-requests: write`. | | |
| 347 | +| A job token's approval doesn't satisfy required reviews | Only people's approvals count. | | |
| 348 | +| `agents may not push to protected or default branches; open a pull request` from a job | The run's code came from an agent. Open a pull request for a person to review and merge. | | |
| 349 | +| A push from a job doesn't start the expected workflow | Events caused by a job token start no runs. Use `workflow_dispatch` with `actions: write` or `repository_dispatch` with `contents: write`, or a personal or agent token. | | |
| 350 | +| `Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable` | Add `id-token: write`. `id-token: read` grants nothing. | |
addedapps/diffinery/docs/actions/workflow-syntax.md+812−0View file
| @@ -1,0 +1,812 @@ | ||
| 1 | +--- | |
| 2 | +title: Workflow syntax | |
| 3 | +description: Every workflow, job and step key Diffinery supports, how each behaves, which are ignored, and the exact errors you see when a file is invalid. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +This page is the reference for the YAML in a Diffinery workflow file. Diffinery uses GitHub Actions workflow syntax, so most existing workflows run unchanged; for every key it says whether Diffinery supports it fully, partly or not at all, and what differs from GitHub. | |
| 7 | + | |
| 8 | +For when workflows run, see [Events that trigger workflows](/docs/actions/events). For `${{ }}` expressions and contexts, see [Expressions](/docs/actions/expressions). For how runs execute and how to watch them, see [Actions overview](/docs/actions/overview). | |
| 9 | + | |
| 10 | +## Workflow files | |
| 11 | + | |
| 12 | +- Workflow files live directly in `.github/workflows/` or `.diffinery/workflows/` and end in `.yml` or `.yaml`. Files in subdirectories are ignored. | |
| 13 | +- A file holds one YAML document whose top level is a mapping. Anchors, aliases and merge keys (`<<: *defaults`) are supported. | |
| 14 | +- Keys are case-sensitive, as on GitHub. | |
| 15 | +- A file that fails validation never runs. When a push or merge group commit contains an invalid file, Diffinery records a failed run titled `Invalid workflow file: <first error>` whose single job, **Invalid workflow file**, lists every error. | |
| 16 | + | |
| 17 | +A complete example that uses most features: | |
| 18 | + | |
| 19 | +```yaml | |
| 20 | +name: CI | |
| 21 | +run-name: CI for ${{ github.ref_name }} by ${{ github.actor }} | |
| 22 | + | |
| 23 | +on: | |
| 24 | + push: | |
| 25 | + branches: [main, "release/**"] | |
| 26 | + paths-ignore: ["docs/**"] | |
| 27 | + pull_request: | |
| 28 | + workflow_dispatch: | |
| 29 | + | |
| 30 | +permissions: | |
| 31 | + contents: read | |
| 32 | + packages: write | |
| 33 | + | |
| 34 | +env: | |
| 35 | + NODE_ENV: test | |
| 36 | + | |
| 37 | +defaults: | |
| 38 | + run: | |
| 39 | + shell: bash | |
| 40 | + | |
| 41 | +concurrency: | |
| 42 | + group: ci-${{ github.ref }} | |
| 43 | + cancel-in-progress: true | |
| 44 | + | |
| 45 | +jobs: | |
| 46 | + lint: | |
| 47 | + runs-on: ubuntu-latest | |
| 48 | + steps: | |
| 49 | + - uses: actions/checkout@v4 | |
| 50 | + - run: npm ci && npm run lint | |
| 51 | + | |
| 52 | + test: | |
| 53 | + needs: lint | |
| 54 | + name: Test (node ${{ matrix.node }}) | |
| 55 | + runs-on: ubuntu-latest | |
| 56 | + timeout-minutes: 30 | |
| 57 | + strategy: | |
| 58 | + fail-fast: false | |
| 59 | + max-parallel: 2 | |
| 60 | + matrix: | |
| 61 | + node: [20, 22] | |
| 62 | + include: | |
| 63 | + - node: 22 | |
| 64 | + coverage: true | |
| 65 | + services: | |
| 66 | + postgres: | |
| 67 | + image: postgres:16 | |
| 68 | + env: | |
| 69 | + POSTGRES_PASSWORD: postgres | |
| 70 | + ports: ["5432:5432"] | |
| 71 | + options: --health-cmd "pg_isready" --health-interval 5s --health-retries 10 | |
| 72 | + outputs: | |
| 73 | + version: ${{ steps.meta.outputs.version }} | |
| 74 | + steps: | |
| 75 | + - uses: actions/checkout@v4 | |
| 76 | + - uses: actions/setup-node@v4 | |
| 77 | + with: | |
| 78 | + node-version: ${{ matrix.node }} | |
| 79 | + - id: meta | |
| 80 | + run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT" | |
| 81 | + - run: npm ci | |
| 82 | + - run: npm test | |
| 83 | + env: | |
| 84 | + DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres | |
| 85 | + - if: ${{ matrix.coverage }} | |
| 86 | + run: npm run coverage | |
| 87 | + | |
| 88 | + report: | |
| 89 | + needs: test | |
| 90 | + if: always() | |
| 91 | + runs-on: ubuntu-latest | |
| 92 | + steps: | |
| 93 | + - run: echo "Tests ${{ needs.test.result }}, version ${{ needs.test.outputs.version }}" | |
| 94 | +``` | |
| 95 | + | |
| 96 | +## Validate workflow files | |
| 97 | + | |
| 98 | +Check files before you push with the CLI. It uses the same parser as the server and needs no network access or token: | |
| 99 | + | |
| 100 | +```sh | |
| 101 | +diffinery workflow validate # every file in .github/workflows and .diffinery/workflows | |
| 102 | +diffinery workflow validate .github/workflows/ci.yml | |
| 103 | +diffinery workflow validate --json # machine-readable results | |
| 104 | +``` | |
| 105 | + | |
| 106 | +Each problem is printed as `<file>:<line>:<col>: error: <message> (<path>)` or `... warning: ...`, and the command exits with status 1 when any file has an error. | |
| 107 | + | |
| 108 | +- **Errors** make the file invalid; it doesn't run. | |
| 109 | +- **Warnings** don't stop the file from running. You get one for every key Diffinery doesn't know (`unknown top-level key 'x'`, `unknown job key 'x'`, `unknown step key 'x'`, `unknown reusable-workflow job key 'x'`), for every event name it doesn't know (`unknown event 'x'`) and for unknown permission scopes (`unknown permission scope 'x'`). Unknown keys are ignored. The server doesn't show warnings, so run `validate` to see them. | |
| 110 | + | |
| 111 | +On the server, the **Actions** tab, `diffinery workflow list` and `GET /repos/{owner}/{repo}/actions/workflows` show each invalid workflow's errors as `<path>:<line>: <message>`, for example `jobs.build.steps[0]:12: step must have either 'run' or 'uses'`. | |
| 112 | + | |
| 113 | +## Top-level keys | |
| 114 | + | |
| 115 | +| Key | Support | Notes | | |
| 116 | +| --- | --- | --- | | |
| 117 | +| `name` | Supported | Display name of the workflow. Default: the file's path. | | |
| 118 | +| `run-name` | Supported | Title of each run. Evaluated with the `github`, `inputs` and `vars` contexts. | | |
| 119 | +| `on` | Supported | The events that start the workflow. Required. See [Events that trigger workflows](/docs/actions/events). | | |
| 120 | +| `permissions` | Supported | What the job token (`GITHUB_TOKEN`) may do, for jobs without their own `permissions`. See [permissions](#permissions). | | |
| 121 | +| `env` | Supported | Environment variables for every job. | | |
| 122 | +| `defaults` | Supported | `defaults.run.shell` and `defaults.run.working-directory` for every `run` step. | | |
| 123 | +| `concurrency` | Supported | Groups runs and optionally cancels older ones. See [Concurrency](#concurrency). | | |
| 124 | +| `jobs` | Supported | The jobs to run. Required, with at least one job. | | |
| 125 | + | |
| 126 | +Any other top-level key gives the warning `unknown top-level key '<key>'` and is ignored. | |
| 127 | + | |
| 128 | +### name | |
| 129 | + | |
| 130 | +```yaml | |
| 131 | +name: CI | |
| 132 | +``` | |
| 133 | + | |
| 134 | +The name appears in the **Actions** sidebar, in run lists, in check names (`CI / test`) and as `github.workflow` and `GITHUB_WORKFLOW`. Without `name`, the file's path (for example `.github/workflows/ci.yml`) is used everywhere a name is needed. `on: workflow_run` matches other workflows by this name. | |
| 135 | + | |
| 136 | +### run-name | |
| 137 | + | |
| 138 | +```yaml | |
| 139 | +run-name: Deploy ${{ inputs.environment }} by @${{ github.actor }} | |
| 140 | +``` | |
| 141 | + | |
| 142 | +Sets each run's title. It's evaluated once, when the run is created, with the `github`, `inputs` and `vars` contexts (as on GitHub). Without `run-name`, or if evaluation fails, the default title is used: the first line of the commit message for pushes, the pull request title for pull request events, `Merge group for #<n>: <title>` for merge groups, the release's name (or tag) for `release`, the `event_type` for `repository_dispatch`, and the workflow name for other events. | |
| 143 | + | |
| 144 | +### on | |
| 145 | + | |
| 146 | +Required. Without it the file fails with `workflow is missing the 'on' trigger section`. It can be a single event name, a list of names, or a mapping of event names to their configuration: | |
| 147 | + | |
| 148 | +```yaml | |
| 149 | +on: push | |
| 150 | +``` | |
| 151 | + | |
| 152 | +```yaml | |
| 153 | +on: [push, pull_request] | |
| 154 | +``` | |
| 155 | + | |
| 156 | +```yaml | |
| 157 | +on: | |
| 158 | + push: | |
| 159 | + branches: [main] | |
| 160 | + schedule: | |
| 161 | + - cron: "0 3 * * 1-5" | |
| 162 | +``` | |
| 163 | + | |
| 164 | +An empty list or mapping fails with `'on' must define at least one event`. Event names, activity types, filters and the payload of each event are documented in [Events that trigger workflows](/docs/actions/events). | |
| 165 | + | |
| 166 | +### env | |
| 167 | + | |
| 168 | +```yaml | |
| 169 | +env: | |
| 170 | + NODE_ENV: production | |
| 171 | + RETRIES: 3 | |
| 172 | + VERBOSE: false | |
| 173 | +``` | |
| 174 | + | |
| 175 | +A mapping of variable names to strings, numbers or booleans (numbers and booleans become strings; an empty value becomes an empty string). Values may contain expressions; they're evaluated when each job starts. A value that is a list or mapping fails with `env.<name> must be a string, number or boolean`, and `env: ${{ fromJSON(...) }}` fails with `env must be a mapping (expressions producing whole mappings are not supported here)`. | |
| 176 | + | |
| 177 | +Workflow `env` is merged with each job's `env` (the job wins) and then with each step's `env` (the step wins). Variables named `GITHUB_*` or `RUNNER_*` that Diffinery sets can't be overridden; other defaults such as `CI` can. See [Default environment variables](/docs/actions/expressions#default-environment-variables). | |
| 178 | + | |
| 179 | +### defaults | |
| 180 | + | |
| 181 | +```yaml | |
| 182 | +defaults: | |
| 183 | + run: | |
| 184 | + shell: bash | |
| 185 | + working-directory: ./app | |
| 186 | +``` | |
| 187 | + | |
| 188 | +`defaults.run.shell` and `defaults.run.working-directory` apply to every `run` step that doesn't set its own. Job-level `defaults` override workflow-level ones key by key. They don't apply to `uses` steps or to the steps inside composite actions, as on GitHub. Values may contain expressions. | |
| 189 | + | |
| 190 | +### permissions | |
| 191 | + | |
| 192 | +```yaml | |
| 193 | +permissions: | |
| 194 | + contents: read | |
| 195 | + id-token: write | |
| 196 | + packages: write | |
| 197 | +``` | |
| 198 | + | |
| 199 | +`permissions` may be `read-all`, `write-all`, an empty mapping (`{}`), or a mapping of scopes to `read`, `write` or `none`. Recognised scopes: `actions`, `attestations`, `checks`, `contents`, `deployments`, `discussions`, `id-token`, `issues`, `models`, `packages`, `pages`, `pull-requests`, `repository-projects`, `security-events` and `statuses`. Another scope gives a warning; another level fails with `permission '<scope>' must be 'read', 'write' or 'none'`. | |
| 200 | + | |
| 201 | +They work as on GitHub: | |
| 202 | + | |
| 203 | +- A job's `permissions` replace the workflow's. A job with neither gets the repository's default: read-only (`contents` and `packages` read) for repositories created since the setting was added, read and write for older ones. | |
| 204 | +- `read-all` gives every scope `read`; `write-all` gives every scope `write`, including `id-token`; `{}` gives nothing. | |
| 205 | +- In a mapping, every scope you don't list is `none`. A job that pushes images and checks out a private repository needs both `packages: write` and `contents: read`. | |
| 206 | +- Each REST endpoint and git operation needs its scope: `contents` for git fetch and push, files, releases and merges, `issues` for issues and labels, `pull-requests` for pull requests and reviews, `statuses`, `checks`, `deployments`, `actions` and so on. A request without it fails with `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: <scope>: <level>)`. | |
| 207 | +- `id-token: write` lets the job request OIDC tokens (see [OIDC](/docs/actions/oidc)); `id-token: read` grants nothing. `packages` decides what the token can do in the [container registry](/docs/packages/container-registry). | |
| 208 | + | |
| 209 | +Whatever `permissions` says, the token never administers the repository, never reads or changes secrets and variables, never creates or changes workflow files, and follows branch protection. [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions) has the full model, the scope each endpoint needs, and how an admin changes the repository default. | |
| 210 | + | |
| 211 | +### concurrency | |
| 212 | + | |
| 213 | +```yaml | |
| 214 | +concurrency: deploy-production | |
| 215 | +``` | |
| 216 | + | |
| 217 | +```yaml | |
| 218 | +concurrency: | |
| 219 | + group: ${{ github.workflow }}-${{ github.ref }} | |
| 220 | + cancel-in-progress: true | |
| 221 | +``` | |
| 222 | + | |
| 223 | +`group` is evaluated when the run is created, with the `github`, `inputs` and `vars` contexts. `cancel-in-progress` is `true`, `false` (the default) or an expression evaluated with the same contexts. With `true`, a new run cancels every unfinished run in its group. With `false`, the new run waits while another run in the group is in progress, and cancels older runs still waiting there (as on GitHub, a group has one waiting run at most). Groups are shared by all workflows of the repository, but a run on a protected branch is only cancelled by runs on that same branch; runs from elsewhere wait for it. See [Concurrency](/docs/actions/overview#concurrency) for the differences from GitHub. | |
| 224 | + | |
| 225 | +A value that is neither a string nor a mapping with `group` fails with `concurrency must be a group string or a mapping with 'group'`. | |
| 226 | + | |
| 227 | +## Jobs | |
| 228 | + | |
| 229 | +`jobs` is a mapping of job IDs to job definitions. It's required (`workflow is missing the 'jobs' section`), must be a mapping (`'jobs' must be a mapping of job id to job`) and must hold at least one job (`'jobs' must define at least one job`). | |
| 230 | + | |
| 231 | +A job ID must start with a letter or `_` and contain only letters, digits, `-` and `_`; otherwise the file fails with `job id '<id>' must start with a letter or '_' and contain only alphanumerics, '-' or '_'`. | |
| 232 | + | |
| 233 | +### Job keys | |
| 234 | + | |
| 235 | +| Key | Support | Notes | | |
| 236 | +| --- | --- | --- | | |
| 237 | +| `name` | Supported | Display name. May use `matrix`; evaluated for each matrix combination. | | |
| 238 | +| `needs` | Supported | Jobs that must finish first. | | |
| 239 | +| `if` | Supported | Condition evaluated before the job starts. | | |
| 240 | +| `runs-on` | Supported | Hosted Linux labels or self-hosted labels, plus `diffinery-large`, `-medium`, `-small` or `-tiny` to pick a hosted [runner size](/docs/actions/runners#runner-sizes). May use `matrix`; evaluated for each matrix combination. `group` is ignored. | | |
| 241 | +| `environment` | Supported | The deployment environment: its branch policy, required reviewers, wait timer, secrets and variables apply, and a deployment is recorded. See [environment](#environment). | | |
| 242 | +| `outputs` | Supported | Values passed to dependent jobs through `needs`. | | |
| 243 | +| `env` | Supported | Environment variables for every step of the job. | | |
| 244 | +| `defaults` | Supported | `defaults.run` for the job's `run` steps. | | |
| 245 | +| `steps` | Supported | Required for jobs that don't call a reusable workflow. | | |
| 246 | +| `timeout-minutes` | Supported | Default and maximum 360. | | |
| 247 | +| `strategy` | Supported | `matrix` with `include` and `exclude`, `fail-fast`, `max-parallel`. | | |
| 248 | +| `continue-on-error` | Supported | A failing job doesn't fail the run. See [continue-on-error (job)](#continue-on-error-job). | | |
| 249 | +| `container` | Supported | Runs the steps in a Docker container. | | |
| 250 | +| `services` | Supported | Starts service containers next to the job. | | |
| 251 | +| `uses` | Supported | Calls a reusable workflow. | | |
| 252 | +| `with` | Supported | Inputs for a reusable workflow. | | |
| 253 | +| `secrets` | Supported | Secrets for a reusable workflow, or `inherit`. | | |
| 254 | +| `permissions` | Supported | See [permissions (job)](#permissions-job). | | |
| 255 | +| `concurrency` | Supported | One job of a group runs at a time. See [concurrency (job)](#concurrency-job). | | |
| 256 | +| `snapshot` | Accepted, ignored | | | |
| 257 | + | |
| 258 | +Any other key gives the warning `unknown job key '<key>'`. | |
| 259 | + | |
| 260 | +### name (job) | |
| 261 | + | |
| 262 | +```yaml | |
| 263 | +jobs: | |
| 264 | + test: | |
| 265 | + name: Test on node ${{ matrix.node }} | |
| 266 | +``` | |
| 267 | + | |
| 268 | +The job's display name in the UI and in check names (`<workflow name> / <job name>`). `GITHUB_JOB` and `github.job` stay the job's ID. It can use the `github`, `needs`, `inputs`, `vars`, `matrix` and `strategy` contexts. Without `name`, a job is named after its ID, and each matrix job gets its values appended, for example `test (ubuntu-latest, 22)`. | |
| 269 | + | |
| 270 | +### needs | |
| 271 | + | |
| 272 | +```yaml | |
| 273 | +jobs: | |
| 274 | + build: | |
| 275 | + runs-on: ubuntu-latest | |
| 276 | + steps: [{ run: make }] | |
| 277 | + test: | |
| 278 | + needs: build | |
| 279 | + runs-on: ubuntu-latest | |
| 280 | + steps: [{ run: make test }] | |
| 281 | + deploy: | |
| 282 | + needs: [build, test] | |
| 283 | + runs-on: ubuntu-latest | |
| 284 | + steps: [{ run: make deploy }] | |
| 285 | +``` | |
| 286 | + | |
| 287 | +A job ID or list of job IDs. A dependent job starts only after the jobs it needs have finished, and reads their results and outputs through the `needs` context. Errors: | |
| 288 | + | |
| 289 | +- `Job '<id>' depends on unknown job '<other>'` | |
| 290 | +- `Dependency cycle between jobs: a -> b -> a` | |
| 291 | + | |
| 292 | +A job starts as soon as every job it needs has finished, as on GitHub. It doesn't wait for unrelated jobs. | |
| 293 | + | |
| 294 | +By default a job runs only when every job above it in the `needs` chain succeeded (a job with `continue-on-error: true` that failed counts as a success). If any of them failed, was cancelled or was skipped, the job is skipped too, and so are the jobs that need it. `needs.<id>.result` is `skipped` for a skipped job. Use a status function in `if` to change that, for example `if: ${{ !cancelled() && !failure() }}` to run after a skipped job; see [Status check functions](/docs/actions/expressions#status-check-functions). | |
| 295 | + | |
| 296 | +### if (job) | |
| 297 | + | |
| 298 | +```yaml | |
| 299 | +jobs: | |
| 300 | + deploy: | |
| 301 | + if: github.ref == 'refs/heads/main' && github.event_name == 'push' | |
| 302 | +``` | |
| 303 | + | |
| 304 | +The condition may be written with or without `${{ }}`. It's checked for syntax when the file is parsed (`Invalid expression: ...`) and evaluated just before the job starts, with the `github`, `needs`, `inputs`, `vars` and `env` (workflow-level) contexts. Without a status function the condition is treated as `success() && (<condition>)`, as on GitHub. A job whose condition is false is recorded as `skipped`. A runtime error fails the job with `Error evaluating 'if': <message>`. | |
| 305 | + | |
| 306 | +### runs-on | |
| 307 | + | |
| 308 | +```yaml | |
| 309 | +runs-on: ubuntu-latest | |
| 310 | +``` | |
| 311 | + | |
| 312 | +```yaml | |
| 313 | +runs-on: [self-hosted, linux, gpu] | |
| 314 | +``` | |
| 315 | + | |
| 316 | +```yaml | |
| 317 | +runs-on: | |
| 318 | + labels: [self-hosted, macos] | |
| 319 | +``` | |
| 320 | + | |
| 321 | +A label, a list of labels, or a mapping with `labels` (and `group`, which Diffinery ignores). Required for jobs that don't call a reusable workflow (`job '<id>' is missing 'runs-on'`). | |
| 322 | + | |
| 323 | +| Labels | Where the job runs | | |
| 324 | +| --- | --- | | |
| 325 | +| Only labels matching `ubuntu-*`, `linux`, `diffinery`, `diffinery-*`, `x64` or `amd64` (case-insensitive) | A Diffinery-hosted Linux container (Ubuntu 24.04, x64). `ubuntu-latest`, `ubuntu-22.04` and `ubuntu-24.04` all get the same image. `diffinery-large`, `diffinery-medium`, `diffinery-small` or `diffinery-tiny` picks its size; without one, the job's own earlier runs pick it. See [Runner sizes](/docs/actions/runners#runner-sizes). | | |
| 326 | +| Any list that includes `self-hosted` | A self-hosted runner whose labels include every label in the list. See [Runners](/docs/actions/runners). | | |
| 327 | +| Anything else (for example `windows-latest` or `macos-14` without `self-hosted`) | Nowhere. The job fails with `No Diffinery runner matches runs-on: <labels>. Linux (ubuntu-*) runners are hosted; anything else needs runs-on: [self-hosted, ...] and a registered runner.` | | |
| 328 | + | |
| 329 | +Labels may be expressions, evaluated for each matrix combination with the `github`, `needs`, `inputs`, `vars`, `matrix` and `strategy` contexts. An expression can give one label or a list of labels: | |
| 330 | + | |
| 331 | +```yaml | |
| 332 | +jobs: | |
| 333 | + test: | |
| 334 | + strategy: | |
| 335 | + matrix: | |
| 336 | + runner: [ubuntu-latest, [self-hosted, arm64]] | |
| 337 | + runs-on: ${{ matrix.runner }} | |
| 338 | +``` | |
| 339 | + | |
| 340 | +If an expression fails, only that combination fails, with `Error evaluating the job for this matrix combination: <message>`. If it gives no labels, the job fails with `No Diffinery runner matches runs-on: (no labels). ...`. | |
| 341 | + | |
| 342 | +### environment | |
| 343 | + | |
| 344 | +```yaml | |
| 345 | +environment: production | |
| 346 | +``` | |
| 347 | + | |
| 348 | +```yaml | |
| 349 | +environment: | |
| 350 | + name: production | |
| 351 | + url: https://www.example.com | |
| 352 | +``` | |
| 353 | + | |
| 354 | +The deployment environment the job deploys to: a name, or a mapping with `name` and an optional `url`. Anything else fails the file with `environment must be a name or a mapping with 'name'`. Environments, their branch policies, secrets and variables are managed through the REST API; see [Environments](/docs/actions/environments) for the full guide. | |
| 355 | + | |
| 356 | +```yaml | |
| 357 | +jobs: | |
| 358 | + deploy: | |
| 359 | + runs-on: ubuntu-latest | |
| 360 | + environment: | |
| 361 | + name: ${{ github.ref_name == 'main' && 'production' || 'staging' }} | |
| 362 | + url: https://${{ github.ref_name == 'main' && 'www' || 'staging' }}.example.com | |
| 363 | + steps: | |
| 364 | + - uses: actions/checkout@v4 | |
| 365 | + - run: ./deploy.sh | |
| 366 | + env: | |
| 367 | + API_KEY: ${{ secrets.DEPLOY_API_KEY }} # an environment secret | |
| 368 | + REGION: ${{ vars.REGION }} # an environment variable | |
| 369 | +``` | |
| 370 | + | |
| 371 | +What happens when a job with `environment` starts: | |
| 372 | + | |
| 373 | +1. `name` and `url` are evaluated with the `github`, `needs`, `inputs`, `vars` (repository variables), `matrix` and `strategy` contexts. A `url` that refers to `steps.` is ignored, because step outputs don't exist yet when the job starts. | |
| 374 | +2. The name is checked: it must be 1 to 255 characters without `/`, `\` or control characters, otherwise the job fails with `invalid environment name '<name>'`. Names are case-insensitive. An environment that doesn't exist yet is created, with no branch policy. | |
| 375 | +3. The environment's deployment branch policy is checked against the run's ref (`GITHUB_REF`). If the ref isn't allowed, the job fails before any step runs with `<branch or tag> is not allowed to deploy to <name> due to environment protection rules.` | |
| 376 | +4. A deployment is recorded for the run's commit and ref, with the task `deploy`, marked as a production deployment when the environment is named `production` (in any case), and a deployment status `in_progress` whose log URL is the job's log. When `url` is an `http://` or `https://` URL, it's stored as the status's environment URL. | |
| 377 | +5. The environment's secrets are added to the `secrets` context and its variables to the `vars` context. They win over repository and organization secrets and variables with the same names. `GITHUB_TOKEN` and `DIFFINERY_TOKEN` can't be overridden. | |
| 378 | + | |
| 379 | +When the job finishes, the deployment gets a final status: `success` when the job succeeded, `inactive` when it was cancelled, and `failure` otherwise (including timeouts). Each attempt of a re-run that runs the job again records its own deployment. When the job succeeds, older successful deployments to the same environment become `inactive` (newer ones are left alone, and production deployments are never made inactive this way). Read deployments with `GET /api/v3/repos/{owner}/{repo}/deployments`; webhooks subscribed to `deployment` get a `created` event for each deployment, and those subscribed to `deployment_status` get each status. | |
| 380 | + | |
| 381 | +Environment secrets aren't always handed out, even when the branch policy allows the ref: | |
| 382 | + | |
| 383 | +- Runs on pull request refs (`refs/pull/<n>/merge`, used by `pull_request` runs) never get environment secrets, because their code is whatever the pull request says. | |
| 384 | +- Runs whose code came from an agent (an agent started the run, pushed the branch last, or opened the pull request, and re-runs and `workflow_run` followers of such runs) get environment secrets only when the environment's policy is "protected branches only". | |
| 385 | + | |
| 386 | +In those cases the job still runs, with the environment's variables but without its secrets (an unset secret is `''`). | |
| 387 | + | |
| 388 | +Differences from GitHub: | |
| 389 | + | |
| 390 | +- Required reviewers are people only (Diffinery has no teams), nobody bypasses the rules, and custom deployment protection rules aren't supported. See [Required reviewers and wait timers](/docs/actions/environments#required-reviewers-and-wait-timers). | |
| 391 | +- `url` can't use step outputs. | |
| 392 | +- The environment doesn't change the job token's permissions. To create deployment statuses yourself, give the job `deployments: write`. | |
| 393 | + | |
| 394 | +A job that calls a reusable workflow (`uses`) can't set `environment` (you get the warning `unknown reusable-workflow job key 'environment'` and it's ignored). Set it on the jobs inside the called workflow instead. | |
| 395 | + | |
| 396 | +### outputs | |
| 397 | + | |
| 398 | +```yaml | |
| 399 | +jobs: | |
| 400 | + version: | |
| 401 | + runs-on: ubuntu-latest | |
| 402 | + outputs: | |
| 403 | + tag: ${{ steps.tag.outputs.value }} | |
| 404 | + steps: | |
| 405 | + - id: tag | |
| 406 | + run: echo "value=v$(date +%Y.%m.%d)" >> "$GITHUB_OUTPUT" | |
| 407 | + release: | |
| 408 | + needs: version | |
| 409 | + runs-on: ubuntu-latest | |
| 410 | + steps: | |
| 411 | + - run: echo "Releasing ${{ needs.version.outputs.tag }}" | |
| 412 | +``` | |
| 413 | + | |
| 414 | +A mapping of names to strings, usually expressions over `steps`. They're evaluated after the job's last step, with every context available to steps. An output whose value contains a secret is dropped with the log warning `Skip output '<name>' since it may contain secret.`; one that fails to evaluate is dropped with `Failed to evaluate job output '<name>': <message>`. Outputs appear under **Outputs** on the job page. | |
| 415 | + | |
| 416 | +For a matrix job, the outputs of all its jobs are merged into `needs.<id>.outputs`; when two jobs set the same output, the one that was created later wins. | |
| 417 | + | |
| 418 | +### env (job) | |
| 419 | + | |
| 420 | +Like the [top-level `env`](#env), for one job. Job values override workflow values. Values may use any context available to steps except `env` itself and `steps` (they're evaluated before the first step). | |
| 421 | + | |
| 422 | +### defaults (job) | |
| 423 | + | |
| 424 | +Like the [top-level `defaults`](#defaults), for one job. Overrides the workflow's `defaults.run` key by key. | |
| 425 | + | |
| 426 | +### steps (job) | |
| 427 | + | |
| 428 | +A list of steps, required for a job that doesn't call a reusable workflow (`job '<id>' must have a list of 'steps'`) and not empty (`job '<id>' must have at least one step`). See [Steps](#steps). | |
| 429 | + | |
| 430 | +### timeout-minutes (job) | |
| 431 | + | |
| 432 | +```yaml | |
| 433 | +timeout-minutes: 20 | |
| 434 | +``` | |
| 435 | + | |
| 436 | +A number or an expression (which may use `matrix`; evaluated for each matrix combination). Default: 360. Values above 360 are capped at 360, the longest a job can run on Diffinery. When the time is up, the runner logs `The job running on runner has exceeded the maximum execution time of <n> minutes.`, stops the job's processes, marks the remaining steps cancelled, and the job fails with `The job exceeded the maximum execution time of <n> minutes.` | |
| 437 | + | |
| 438 | +### strategy | |
| 439 | + | |
| 440 | +```yaml | |
| 441 | +strategy: | |
| 442 | + fail-fast: true | |
| 443 | + max-parallel: 3 | |
| 444 | + matrix: | |
| 445 | + os: [ubuntu-22.04, ubuntu-24.04] | |
| 446 | + node: [20, 22] | |
| 447 | + exclude: | |
| 448 | + - os: ubuntu-22.04 | |
| 449 | + node: 20 | |
| 450 | + include: | |
| 451 | + - node: 22 | |
| 452 | + experimental: true | |
| 453 | + - os: ubuntu-24.04 | |
| 454 | + node: 23 | |
| 455 | +``` | |
| 456 | + | |
| 457 | +#### matrix | |
| 458 | + | |
| 459 | +`matrix` is a mapping of variables to lists. Diffinery creates one job for every combination, then applies `exclude` and `include` with GitHub's rules: | |
| 460 | + | |
| 461 | +1. Every combination of the lists is built. | |
| 462 | +2. `exclude` entries remove every combination they match. An entry matches when each of its keys equals the combination's value (a partial match is enough). | |
| 463 | +3. Each `include` entry is merged into every remaining combination whose original values it doesn't change. It may add new keys and may overwrite values added by an earlier `include`, but never a value from the original lists. If it can't be merged into any combination, it becomes a new combination of its own. | |
| 464 | + | |
| 465 | +The example above produces `(ubuntu-22.04, 22, experimental)`, `(ubuntu-24.04, 20)`, `(ubuntu-24.04, 22, experimental)` and `(ubuntu-24.04, 23)`. Values are compared by their string form, so `18` equals `'18'`. | |
| 466 | + | |
| 467 | +- A matrix with only `include` produces exactly the `include` entries. | |
| 468 | +- The whole matrix, any list, `include` or `exclude` can be a single `${{ }}` expression, for example `matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}`. Such expressions are evaluated when the job starts, with the `github`, `needs`, `inputs`, `vars` and `env` contexts. | |
| 469 | +- A matrix may produce at most 256 jobs. A larger one fails the job with `A matrix may produce at most 256 jobs.` | |
| 470 | +- Before `exclude` is applied, the vectors may have at most 4,096 combinations, and `include` and `exclude` at most 256 entries each. A larger matrix is refused before it is expanded (at parse time, or at run time for one built with `fromJSON`). | |
| 471 | +- The `matrix` and `strategy` contexts give each job its values (`strategy.job-index`, `strategy.job-total`, `strategy.fail-fast`, `strategy.max-parallel`). | |
| 472 | + | |
| 473 | +Parse and runtime errors: `matrix produces no job combinations`, `matrix.<key> must be a list (got <type>)`, `matrix.include must be a list of mappings`, `matrix.exclude[<n>] must be a mapping`, `matrix must be a mapping or a single ${{ }} expression`, `the matrix has <n> combinations before exclusions; at most 4096 are allowed (and a matrix may produce at most 256 jobs)`, `matrix.include has <n> entries; at most 256 are allowed`, and at runtime `Error expanding matrix: <message>`. | |
| 474 | + | |
| 475 | +#### fail-fast | |
| 476 | + | |
| 477 | +`true` (the default), `false` or an expression. With `true`, when one job of the matrix fails or times out (and doesn't have `continue-on-error: true`), its unfinished sibling jobs are stopped and marked `cancelled` with the reason `Cancelled because <job name> failed (fail-fast).` | |
| 478 | + | |
| 479 | +#### max-parallel | |
| 480 | + | |
| 481 | +A number or an expression. At most this many jobs of the matrix run at the same time; the others stay queued and start as running ones finish. Default: all at once. When `fail-fast` stops the matrix, queued jobs are cancelled too. | |
| 482 | + | |
| 483 | +### continue-on-error (job) | |
| 484 | + | |
| 485 | +```yaml | |
| 486 | +jobs: | |
| 487 | + test: | |
| 488 | + runs-on: ubuntu-latest | |
| 489 | + continue-on-error: ${{ matrix.experimental }} | |
| 490 | + strategy: | |
| 491 | + matrix: | |
| 492 | + node: [20, 22] | |
| 493 | + experimental: [false] | |
| 494 | + include: | |
| 495 | + - node: 24 | |
| 496 | + experimental: true | |
| 497 | + steps: | |
| 498 | + - uses: actions/setup-node@v4 | |
| 499 | + with: | |
| 500 | + node-version: ${{ matrix.node }} | |
| 501 | + - run: npm ci && npm test | |
| 502 | +``` | |
| 503 | + | |
| 504 | +`true`, `false` or an expression, evaluated for each matrix combination (it may use `matrix` and `strategy`). When a job with `continue-on-error: true` fails or times out: | |
| 505 | + | |
| 506 | +- The job itself still shows as failed, but the run's conclusion isn't `failure` because of it. | |
| 507 | +- It doesn't trigger `fail-fast`, so its matrix siblings keep running. | |
| 508 | +- It counts as a success in `needs.<id>.result` and for the `success()` checks of the jobs that need it. | |
| 509 | + | |
| 510 | +### container | |
| 511 | + | |
| 512 | +```yaml | |
| 513 | +container: | |
| 514 | + image: node:22-bookworm | |
| 515 | + credentials: | |
| 516 | + username: ${{ secrets.REGISTRY_USER }} | |
| 517 | + password: ${{ secrets.REGISTRY_TOKEN }} | |
| 518 | + env: | |
| 519 | + NODE_ENV: test | |
| 520 | + ports: [8080] | |
| 521 | + volumes: ["/data:/data"] | |
| 522 | + options: --cpus 2 | |
| 523 | +``` | |
| 524 | + | |
| 525 | +Runs every `run` step and JavaScript action of the job inside a container started from `image`. A string is short for `image:`. Keys: `image` (required), `credentials` (`username` and `password`, both required when present), `env`, `ports`, `volumes` and `options` (extra `docker run` flags; `--network` is rejected because every container of a job shares one network). Values may use expressions. Hosted runners have Docker; self-hosted runners need it installed. Details are in [Docker in workflows](/docs/actions/docker). | |
| 526 | + | |
| 527 | +### services | |
| 528 | + | |
| 529 | +```yaml | |
| 530 | +services: | |
| 531 | + redis: | |
| 532 | + image: redis:7 | |
| 533 | + ports: ["6379:6379"] | |
| 534 | + options: --health-cmd "redis-cli ping" --health-interval 2s --health-retries 15 | |
| 535 | +``` | |
| 536 | + | |
| 537 | +A mapping of service names to the same keys as `container`. Each service starts before the first step, on the job's network, and Diffinery waits for its health check when the image or `options` define one. Steps in a job container reach a service by its name (`redis:6379`); steps on the runner reach it on `localhost` through its mapped ports. A service whose `image` evaluates to an empty string is skipped. See [Docker in workflows](/docs/actions/docker). | |
| 538 | + | |
| 539 | +### uses, with and secrets (reusable workflows) | |
| 540 | + | |
| 541 | +```yaml | |
| 542 | +jobs: | |
| 543 | + call-tests: | |
| 544 | + uses: ./.github/workflows/tests.yml | |
| 545 | + with: | |
| 546 | + node-version: 22 | |
| 547 | + secrets: inherit | |
| 548 | + deploy: | |
| 549 | + needs: call-tests | |
| 550 | + uses: acme/shared-workflows/.github/workflows/deploy.yml@v2 | |
| 551 | + with: | |
| 552 | + environment: production | |
| 553 | + secrets: | |
| 554 | + token: ${{ secrets.DEPLOY_TOKEN }} | |
| 555 | +``` | |
| 556 | + | |
| 557 | +A job with `uses` calls a reusable workflow instead of running steps. `uses` must be `./path/to/workflow.yml` (from the same commit) or `owner/repo/path/to/workflow.yml@ref` (from a repository on the same Diffinery instance); otherwise the file fails with `reusable workflow reference '<value>' must be './path/to/workflow.yml' or 'owner/repo/path/to/workflow.yml@ref'`. | |
| 558 | + | |
| 559 | +Such a job may only have `name`, `uses`, `with`, `secrets`, `needs`, `if`, `permissions`, `concurrency` and `strategy`. `steps` and `runs-on` are errors (`a job that calls a reusable workflow ('uses') cannot have 'steps'`); other keys give the warning `unknown reusable-workflow job key '<key>'`. A `matrix` on a calling job isn't supported yet and fails the run with `jobs.<id>: a matrix on a job that calls a reusable workflow is not supported yet`. | |
| 560 | + | |
| 561 | +Calls nest up to 4 levels deep, and one run can call at most 20 reusable workflows. Inputs, secrets, outputs and every error message are in [Reusable workflows](/docs/actions/reusable-workflows). | |
| 562 | + | |
| 563 | +### permissions (job) | |
| 564 | + | |
| 565 | +Same syntax as the [top-level `permissions`](#permissions). The job's value replaces the workflow's completely, as on GitHub: scopes the workflow grants but the job's mapping leaves out are `none` for this job. In a [reusable workflow](/docs/actions/reusable-workflows), a job's permissions can't exceed the calling job's. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions). | |
| 566 | + | |
| 567 | +### concurrency (job) | |
| 568 | + | |
| 569 | +```yaml | |
| 570 | +jobs: | |
| 571 | + deploy: | |
| 572 | + runs-on: ubuntu-latest | |
| 573 | + concurrency: | |
| 574 | + group: deploy-${{ github.ref }} | |
| 575 | + cancel-in-progress: true | |
| 576 | +``` | |
| 577 | + | |
| 578 | +Same syntax as the top-level `concurrency`. At most one job of a group runs at a time across the whole repository, whichever run or workflow it belongs to. `group` and `cancel-in-progress` are evaluated for each matrix combination, with the `github`, `needs`, `inputs`, `vars`, `matrix` and `strategy` contexts. | |
| 579 | + | |
| 580 | +- Without `cancel-in-progress`, a job waits with the status `pending` while another job of its group runs. Only the newest pending job is kept: a new pending job cancels the older one. | |
| 581 | +- With `cancel-in-progress: true`, the new job starts and the group's running and pending jobs are cancelled. | |
| 582 | +- A job cancelled by its group ends with the message `Canceling since a higher priority waiting request for '<group>' exists`. | |
| 583 | +- When a running job finishes, the oldest pending job of the group starts. | |
| 584 | + | |
| 585 | +The concurrency of a job that calls a reusable workflow is ignored; set it on the jobs inside the called workflow. | |
| 586 | + | |
| 587 | +## Steps | |
| 588 | + | |
| 589 | +Each item in `steps` is a mapping. A step runs either a shell script (`run`) or an action (`uses`), never both. | |
| 590 | + | |
| 591 | +| Key | Support | Notes | | |
| 592 | +| --- | --- | --- | | |
| 593 | +| `id` | Supported | Lets later steps read this step through `steps.<id>`. | | |
| 594 | +| `if` | Supported | Condition evaluated before the step. | | |
| 595 | +| `name` | Supported | Display name; may use expressions. | | |
| 596 | +| `uses` | Supported | An action: `owner/repo@ref`, `owner/repo/path@ref`, `./path` or `docker://image`. | | |
| 597 | +| `run` | Supported | A script run by `shell`. | | |
| 598 | +| `shell` | Supported | `bash`, `sh`, `python`, `pwsh`, `powershell` or a custom command with `{0}`. `cmd` fails. | | |
| 599 | +| `with` | Supported | Action inputs; scalar values only. | | |
| 600 | +| `env` | Supported | Environment variables for this step. | | |
| 601 | +| `continue-on-error` | Supported | `true`, `false` or an expression. | | |
| 602 | +| `timeout-minutes` | Partly | A number works; an expression is accepted but ignored. | | |
| 603 | +| `working-directory` | Supported | For `run` steps. | | |
| 604 | + | |
| 605 | +Any other key gives the warning `unknown step key '<key>'`. A step with neither `run` nor `uses` fails with `step must have either 'run' or 'uses'`; one with both fails with `step cannot have both 'run' and 'uses'`. | |
| 606 | + | |
| 607 | +### id | |
| 608 | + | |
| 609 | +```yaml | |
| 610 | +- id: build | |
| 611 | + run: echo "artifact=dist/app.tar.gz" >> "$GITHUB_OUTPUT" | |
| 612 | +- run: echo "${{ steps.build.outputs.artifact }} ${{ steps.build.outcome }}" | |
| 613 | +``` | |
| 614 | + | |
| 615 | +Must start with a letter or `_` and contain only letters, digits, `-` and `_` (`step id '<id>' must start with a letter or '_' and contain only alphanumerics, '-' or '_'`), and be unique within the job (`duplicate step id '<id>'`). The step's ID is also `GITHUB_ACTION` while it runs (`__run` for a `run` step without an ID). | |
| 616 | + | |
| 617 | +### if (step) | |
| 618 | + | |
| 619 | +```yaml | |
| 620 | +- if: failure() && steps.test.outcome == 'failure' | |
| 621 | + run: ./collect-crash-logs.sh | |
| 622 | +``` | |
| 623 | + | |
| 624 | +With or without `${{ }}`. Evaluated just before the step, with every step context. Without a status function it's treated as `success() && (<condition>)`: the step is skipped once an earlier step has failed (and not set `continue-on-error`), or once the job is being cancelled. A skipped step's `outcome` and `conclusion` are `skipped`. An evaluation error fails the step with `Error evaluating 'if' condition: <message>`. | |
| 625 | + | |
| 626 | +### name (step) | |
| 627 | + | |
| 628 | +The step's title in the log. Without it, Diffinery uses `Run <uses>` or `Run <first line of the script>`, as on GitHub. | |
| 629 | + | |
| 630 | +### uses | |
| 631 | + | |
| 632 | +```yaml | |
| 633 | +- uses: actions/checkout@v4 # an action repository | |
| 634 | +- uses: actions/cache/restore@v4 # an action in a subdirectory | |
| 635 | +- uses: ./.github/actions/setup # a local action in the workspace | |
| 636 | +- uses: docker://alpine:3.20 # a Docker image | |
| 637 | + with: | |
| 638 | + entrypoint: /bin/sh | |
| 639 | + args: -c "echo hello" | |
| 640 | +``` | |
| 641 | + | |
| 642 | +- `actions/checkout`, `actions/cache` (and `cache/restore`, `cache/save`), `actions/upload-artifact`, `actions/download-artifact`, `actions/setup-node`, `actions/setup-python` and `actions/setup-go` are built into the runner for their current major versions. A commit SHA, a branch, a newer major version, an input the built-in doesn't know, or `DIFFINERY_BUILTINS: false` in `env` runs the real action instead (see [Built-in actions](/docs/actions/compatibility#built-in-actions)). | |
| 643 | +- Other `owner/repo@ref` actions are downloaded from GitHub and run with their `action.yml`: JavaScript (`node*`), composite and Docker actions are supported. JavaScript actions run with the runner's Node.js 22 whatever `runs.using` says. | |
| 644 | +- `./path` actions are read from the workspace, so check out the repository first. Otherwise the step fails with `Can't find 'action.yml' or 'action.yaml' under '<dir>'. Did you forget to run actions/checkout before running your local action?` | |
| 645 | +- `docker://image` runs a container; `with.args` and `with.entrypoint` set its arguments and entrypoint. | |
| 646 | +- `actions/github-script` and other actions that call the API work with the job token, within its `permissions`. | |
| 647 | +- A reference without `@ref` fails with `Expected format {owner}/{repo}[/path]@ref. Actual '<value>'`. An unknown action or version fails with `Unable to resolve action '<owner>/<repo>@<ref>', repository or version not found`. | |
| 648 | + | |
| 649 | +What each built-in action supports is in [GitHub Actions compatibility](/docs/actions/compatibility) and [Caching and artifacts](/docs/actions/caching-and-artifacts). | |
| 650 | + | |
| 651 | +### with | |
| 652 | + | |
| 653 | +```yaml | |
| 654 | +- uses: actions/setup-node@v4 | |
| 655 | + with: | |
| 656 | + node-version: 22 | |
| 657 | + cache: npm | |
| 658 | +``` | |
| 659 | + | |
| 660 | +A mapping of input names to strings, numbers or booleans; lists and mappings fail with `<path> must be a string, number or boolean`. Values may contain expressions. JavaScript and Docker actions receive each input as `INPUT_<NAME>` (upper case, spaces replaced by `_`). Defaults from the action's `action.yml` fill in missing inputs; a missing required input without a default logs `Input required and not supplied: <name>`. | |
| 661 | + | |
| 662 | +### run | |
| 663 | + | |
| 664 | +```yaml | |
| 665 | +- name: Build | |
| 666 | + run: | | |
| 667 | + npm ci | |
| 668 | + npm run build | |
| 669 | +``` | |
| 670 | + | |
| 671 | +The script is written to a temporary file and run with the step's shell from `working-directory` (default: `GITHUB_WORKSPACE`). Expressions in it are replaced before it runs. The step fails when the script exits with a non-zero status: `Process completed with exit code <n>.` | |
| 672 | + | |
| 673 | +### shell | |
| 674 | + | |
| 675 | +| Value | Command | Notes | | |
| 676 | +| --- | --- | --- | | |
| 677 | +| (not set) | `bash --noprofile --norc -eo pipefail {0}` | Falls back to `sh -e {0}` when `bash` isn't installed (for example in an Alpine job container). | | |
| 678 | +| `bash` | `bash --noprofile --norc -eo pipefail {0}` | | | |
| 679 | +| `sh` | `sh -e {0}` | | | |
| 680 | +| `python` | `python {0}` | Uses `python3` when there is no `python`. | | |
| 681 | +| `pwsh` | `pwsh -command ". '{0}'"` | PowerShell isn't installed on hosted runners; use a job container or self-hosted runner that has it. | | |
| 682 | +| `powershell` | `powershell -command ". '{0}'"` | Uses `pwsh` when there is no `powershell`. | | |
| 683 | +| `cmd` | | Fails with ``The `cmd` shell is not supported on Linux runners.`` | | |
| 684 | +| A command containing `{0}` | As written, with `{0}` replaced by the script's path | For example `node {0}` or `perl {0}`. The script file gets `.py`, `.ps1`, `.js` or `.sh` depending on the command. | | |
| 685 | + | |
| 686 | +Anything else fails with `Invalid shell option '<value>'. Shell must be a valid built-in (bash, sh, python, pwsh) or a format string containing '{0}'`. A shell that isn't installed fails with `<command>: command not found. The shell '<value>' is not available on this runner.` (or `in the job container`). | |
| 687 | + | |
| 688 | +> **Note:** On GitHub, a step without `shell` runs `bash -e {0}`, and only an explicit `shell: bash` adds `--noprofile --norc -o pipefail`. Diffinery always uses the stricter form, so a pipeline like `false | true` fails a step even when `shell` isn't set. | |
| 689 | + | |
| 690 | +### env (step) | |
| 691 | + | |
| 692 | +```yaml | |
| 693 | +- run: ./deploy.sh | |
| 694 | + env: | |
| 695 | + API_TOKEN: ${{ secrets.API_TOKEN }} | |
| 696 | + TARGET: staging | |
| 697 | +``` | |
| 698 | + | |
| 699 | +Variables for this step only, on top of the workflow's, the job's and those added with `$GITHUB_ENV`. A step's values can refer to its earlier keys through `env`. | |
| 700 | + | |
| 701 | +### continue-on-error (step) | |
| 702 | + | |
| 703 | +```yaml | |
| 704 | +- run: npm run flaky-e2e | |
| 705 | + continue-on-error: true | |
| 706 | +``` | |
| 707 | + | |
| 708 | +`true`, `false` or an expression that evaluates to `true` or `false`. When the step fails, its `outcome` is `failure` but its `conclusion` is `success`, the log shows `Step failed but continue-on-error is set; continuing.`, and later steps run as if it had succeeded. | |
| 709 | + | |
| 710 | +### timeout-minutes (step) | |
| 711 | + | |
| 712 | +```yaml | |
| 713 | +- run: npm test | |
| 714 | + timeout-minutes: 10 | |
| 715 | +``` | |
| 716 | + | |
| 717 | +A number of minutes (fractions allowed). When it's up, the step's processes are stopped and the step fails with `The step has timed out after <n> minutes.` Without it, only the job's timeout applies. | |
| 718 | + | |
| 719 | +> **Note:** The parser accepts an expression here, but Diffinery currently ignores it and the step runs without its own timeout. Use a plain number. | |
| 720 | + | |
| 721 | +### working-directory | |
| 722 | + | |
| 723 | +```yaml | |
| 724 | +- run: npm test | |
| 725 | + working-directory: packages/api | |
| 726 | +``` | |
| 727 | + | |
| 728 | +The directory a `run` step starts in, relative to `GITHUB_WORKSPACE` unless absolute. It must exist: otherwise the step fails with `An error occurred trying to start process '<shell>' with working directory '<dir>'. No such file or directory`. | |
| 729 | + | |
| 730 | +## Workflow commands | |
| 731 | + | |
| 732 | +Steps talk to the runner by printing lines in the form `::command key=value,key=value::message` to standard output or standard error, as on GitHub. | |
| 733 | + | |
| 734 | +| Command | Effect | | |
| 735 | +| --- | --- | | |
| 736 | +| `::add-mask::<value>` | Masks the value as `***` in every later log line. Values shorter than 3 characters are not masked. | | |
| 737 | +| `::group::<title>` and `::endgroup::` | Folds the lines between them under a title in the log. | | |
| 738 | +| `::error file=<f>,line=<n>,title=<t>::<message>` | Logs an error. When the step fails, the last error it printed becomes the job's failure reason. | | |
| 739 | +| `::warning file=<f>,line=<n>,title=<t>::<message>` | Logs a warning. | | |
| 740 | +| `::notice file=<f>,line=<n>,title=<t>::<message>` | Logs a notice. | | |
| 741 | +| `::debug::<message>` | Logged only when debug logging is on: set the secret or variable `ACTIONS_STEP_DEBUG` to `true`. Debug logging also sets `RUNNER_DEBUG=1` and `runner.debug`. | | |
| 742 | +| `::set-output name=<name>::<value>` | Sets a step output. Deprecated on GitHub but still works; prefer `$GITHUB_OUTPUT`. | | |
| 743 | +| `::save-state name=<name>::<value>` | Saves state for an action's post step (`STATE_<name>`). | | |
| 744 | +| `::stop-commands::<token>` ... `::<token>::` | Stops processing commands until the token line, so you can print untrusted text. | | |
| 745 | +| `::echo::on` and `::echo::off` | Echoes processed commands into the log. | | |
| 746 | +| `::set-env` and `::add-path` | Disabled, as on GitHub. They log ``The `set-env` command is disabled. Please upgrade to using Environment Files.`` | | |
| 747 | + | |
| 748 | +Annotations (`error`, `warning`, `notice`) are shown in the log with their file, line and title; only `file`, `line` and `title` are read, and other properties such as `col` are ignored. Diffinery doesn't attach annotations to files in pull requests. | |
| 749 | + | |
| 750 | +## Environment files | |
| 751 | + | |
| 752 | +Each step gets fresh, empty files whose paths are in these variables. Write to them to pass data on: | |
| 753 | + | |
| 754 | +| Variable | Format | Effect | | |
| 755 | +| --- | --- | --- | | |
| 756 | +| `GITHUB_OUTPUT` | `name=value`, or `name<<EOF` ... `EOF` for multi-line values | Sets the step's outputs (`steps.<id>.outputs.<name>`). | | |
| 757 | +| `GITHUB_ENV` | `NAME=value`, or `NAME<<EOF` ... `EOF` | Sets environment variables for the following steps. `NODE_OPTIONS` values containing `--require` or `-r` are ignored. | | |
| 758 | +| `GITHUB_PATH` | One directory per line | Prepends the directories to `PATH` for the following steps. | | |
| 759 | +| `GITHUB_STEP_SUMMARY` | Markdown | Adds to the job summary shown on the job page. | | |
| 760 | +| `GITHUB_STATE` | `name=value` | Saves state for an action's post step. | | |
| 761 | + | |
| 762 | +```yaml | |
| 763 | +- id: info | |
| 764 | + run: | | |
| 765 | + echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" | |
| 766 | + { | |
| 767 | + echo "CHANGELOG<<EOF" | |
| 768 | + git log -5 --oneline | |
| 769 | + echo "EOF" | |
| 770 | + } >> "$GITHUB_ENV" | |
| 771 | + echo "$HOME/.local/bin" >> "$GITHUB_PATH" | |
| 772 | +``` | |
| 773 | + | |
| 774 | +A malformed line logs `Unable to process file command '<env|path|output|state>' successfully: Invalid file command format: <line>` (or `Matching delimiter not found '<delimiter>'`) and marks an error. | |
| 775 | + | |
| 776 | +## Masking | |
| 777 | + | |
| 778 | +Every secret's value (each line separately for multi-line secrets), the job's token and every value passed to `::add-mask::` are replaced by `***` in log lines, step names, the job summary and the failure reason. Values shorter than 3 characters are never masked, because masking them would make logs unreadable. A job output that contains a masked value is dropped. | |
| 779 | + | |
| 780 | +## Validation errors | |
| 781 | + | |
| 782 | +The most common errors from the parser, with what to do: | |
| 783 | + | |
| 784 | +| Message | Fix | | |
| 785 | +| --- | --- | | |
| 786 | +| `YAML syntax error: <detail>` | Fix the YAML at the reported line (indentation, quotes, tabs). | | |
| 787 | +| `workflow file is empty` | Add content, or delete the file. | | |
| 788 | +| `workflow is missing the 'on' trigger section` | Add `on:`. | | |
| 789 | +| `workflow is missing the 'jobs' section` | Add `jobs:`. | | |
| 790 | +| `job '<id>' is missing 'runs-on'` | Add `runs-on: ubuntu-latest`. | | |
| 791 | +| `job '<id>' must have a list of 'steps'` | Add `steps:` with at least one step. | | |
| 792 | +| `step must have either 'run' or 'uses'` | Give the step a `run` or `uses`. | | |
| 793 | +| `'branches' and 'branches-ignore' cannot be used together for the same event` | Use one of them, with `!` patterns for exceptions. The same applies to `tags`/`tags-ignore` and `paths`/`paths-ignore`. | | |
| 794 | +| `Invalid cron: <detail>` | Fix the `schedule` cron expression; see [schedule](/docs/actions/events#schedule). | | |
| 795 | +| `Unclosed expression: '${{' at offset <n> has no matching '}}'` | Close the expression. | | |
| 796 | +| `Invalid expression ${{ <expr> }}: <detail>` | Fix the expression syntax; see [Expressions](/docs/actions/expressions). | | |
| 797 | +| `<path> must be a mapping (expressions producing whole mappings are not supported here)` | Write `env`, `with` or `outputs` as a mapping and use expressions in the values. | | |
| 798 | +| `input '<name>' of type 'choice' must define 'options'` | Add `options:` to the `workflow_dispatch` input. | | |
| 799 | +| `input '<name>' must declare a 'type'` | `workflow_call` inputs need a `type`. | | |
| 800 | + | |
| 801 | +## Limits | |
| 802 | + | |
| 803 | +| Limit | Value | | |
| 804 | +| --- | --- | | |
| 805 | +| Job timeout | 360 minutes (default and maximum) | | |
| 806 | +| Jobs per matrix | 256 | | |
| 807 | +| Reusable workflow nesting | 4 levels | | |
| 808 | +| Reusable workflows called per run | 20 | | |
| 809 | +| Composite action nesting | 10 levels | | |
| 810 | +| Characters per log line | 16,384 (longer lines are cut and end with `…`) | | |
| 811 | + | |
| 812 | +See [Limits](/docs/reference/limits) for the rest of Diffinery's limits. |
addedapps/diffinery/docs/admin/site-administration.md+516−0View file
| @@ -1,0 +1,516 @@ | ||
| 1 | +--- | |
| 2 | +title: Site administration | |
| 3 | +description: Site administrators manage people and admins, handle abuse and security reports, suspend and delete accounts, stop Actions, post status incidents, oversee imports, and check the audit and email logs. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +Site administrators run a Diffinery instance. They decide who gets in, help people who lost their passkeys, handle private security reports, post incidents on the status page, and check the audit log and email delivery. This page covers who the site administrators are and each page in the **Site administration** group of **Settings**. | |
| 7 | + | |
| 8 | +## Site administrators | |
| 9 | + | |
| 10 | +Site administrators have admin on every repository, can act as an owner of every organization (including its **Settings** tab, where the organization's name, picture and base permission are changed, and the **Add people** form on its **People** tab), and see an extra **Site administration** group in the **Settings** sidebar with these pages: | |
| 11 | + | |
| 12 | +| Page | Path | What it is for | | |
| 13 | +| --- | --- | --- | | |
| 14 | +| **People and invitations** | `/settings/admin` | Access requests, instance invitations, the list of users, account status and sign-in links | | |
| 15 | +| **Early access** | `/settings/admin/early-access` | Requests from people without an invitation, to invite, decline or delete | | |
| 16 | +| **Reports** | `/settings/admin/reports` | What people reported as abusive, to look at and resolve | | |
| 17 | +| **Email** | `/settings/admin/email` | Email delivery status, the suppression list, template previews and the log of every email | | |
| 18 | +| **Kill switches** | `/settings/admin/kill-switches` | Stop Actions and agent sandboxes for a repository, an account or the whole instance | | |
| 19 | +| **Security reports** | `/settings/admin/security-reports` | Private vulnerability reports from the report form, to read, triage and resolve | | |
| 20 | +| **Status** | `/settings/admin/status` | Incidents on the public status page, to open, update and resolve | | |
| 21 | +| **Search engines** | `/settings/admin/search-engines` | What Diffinery told search engines through IndexNow about new, changed and removed public pages | | |
| 22 | +| **Audit log** | `/settings/audit` | The most recent security-relevant events | | |
| 23 | + | |
| 24 | +Their **Settings → Profile** page shows **Site admin** as yes, and they have an **admin** badge in the users list. | |
| 25 | + | |
| 26 | +### Who becomes a site administrator | |
| 27 | + | |
| 28 | +The instance setting `BOOTSTRAP_ADMINS` is a comma-separated list of email addresses, set on deploy from the `BOOTSTRAP_ADMINS` environment variable (a deploy without it keeps the list the instance has; see [BOOTSTRAP_ADMINS](/docs/self-hosting/configuration#bootstrap_admins)). An account becomes a site administrator when: | |
| 29 | + | |
| 30 | +- it is created with an email address on the list (from an invite, after opening the link emailed to that address, or by verifying the email address through email recovery at `/auth/access`), or | |
| 31 | +- it was waiting for an invite (pending) and the person verifies an email address on the list at `/auth/access`. | |
| 32 | + | |
| 33 | +Bootstrap administrators don't need an invite. Adding an address to the list later doesn't promote an account that is already active; use **Make admin** instead. See [Configuration](/docs/self-hosting/configuration) for the setting. | |
| 34 | + | |
| 35 | +### Granting and removing site administration | |
| 36 | + | |
| 37 | +In the **Users** table on **Settings → People and invitations**, the **Site admin** column has a **Make admin** button for each active person who isn't a site administrator, and a **Remove admin** button for each one who is. Both ask you to confirm, and both need a sign-in from the last 2 hours: if yours is older, Diffinery asks you to sign in again and brings you back. You see "diffy is now a site administrator." or "diffy is no longer a site administrator." | |
| 38 | + | |
| 39 | +- Only active accounts can be made site administrators. Enable a pending or disabled account first. | |
| 40 | +- The last site administrator can never be removed: "Diffinery needs at least one site administrator: make someone else a site administrator first". You can remove yourself while there is another one; you then lose access to these pages. | |
| 41 | +- The person gets a security email saying who made the change. | |
| 42 | +- Each change is recorded in the [audit log](/docs/security/audit-log) as `site_admin.grant` or `site_admin.revoke`, with who made it and the person it was about. | |
| 43 | + | |
| 44 | +### First sign-in for administrators | |
| 45 | + | |
| 46 | +On a new instance nobody has a passkey yet. A bootstrap administrator signs in through email recovery: | |
| 47 | + | |
| 48 | +1. Open `https://diffinery.com/auth/access`. | |
| 49 | +2. Confirm your email address with the one-time PIN that Cloudflare Access emails you. | |
| 50 | +3. On the page that follows (**Settings → Passkeys and sessions**), click **Add a passkey**. | |
| 51 | + | |
| 52 | +From then on you sign in with the passkey. Email recovery needs Cloudflare Access to be set up for `/auth/access`; see [Passkeys and sign-in](/docs/accounts/passkeys-and-sign-in). | |
| 53 | + | |
| 54 | +### Tokens and site administration | |
| 55 | + | |
| 56 | +A site administrator's tokens are capped by their scope like anyone's: to act as admin on repositories through Git or the API, use an `admin`-scoped token. See [Personal access tokens](/docs/accounts/tokens). | |
| 57 | + | |
| 58 | +## People | |
| 59 | + | |
| 60 | +**Settings → People and invitations** (`/settings/admin`, titled **Site administration**) has three sections: **Pending access requests**, **Invite someone to this instance** and **Users**. Other people get `403 site administrators only`. | |
| 61 | + | |
| 62 | +### Pending access requests | |
| 63 | + | |
| 64 | +People who verified their email address at `/auth/access` without an invite have a pending account and can click **Request access** with a note. Each request is listed with the person's email address, when they asked and their note. Diffinery also emails up to 20 active site administrators about each request. | |
| 65 | + | |
| 66 | +- **Approve** makes the account active. The person can then use Diffinery, but isn't added to any organization or repository; invite them to one, by email address or by username. | |
| 67 | +- **Deny** disables the account. The person gets a "Your account was disabled" email. | |
| 68 | + | |
| 69 | +See [Invitations](/docs/accounts/invitations#access-requests) for the requester's side. | |
| 70 | + | |
| 71 | +### Users | |
| 72 | + | |
| 73 | +The **Users** table lists every person (not organizations, agents or deleted accounts) with their picture and username, email address, when they joined, their status and whether they are a site administrator. Each row has **Make admin** or **Remove admin** (see [Granting and removing site administration](#granting-and-removing-site-administration)), **Sign-in link** (see [Sign-in links](#sign-in-links)) and **Delete** (see [Deleting an account](#deleting-an-account)). For each user except yourself you can pick a status in the menu and click **Save**. You see "User updated." | |
| 74 | + | |
| 75 | +| Status in the menu | Value | Meaning | | |
| 76 | +| --- | --- | --- | | |
| 77 | +| **Active** | `active` | Can sign in and use everything their roles allow | | |
| 78 | +| **Pending** | `pending` | Waiting for an invite: can sign in but only sees the **Waiting for an invite** page | | |
| 79 | +| **Disabled** | `disabled` | Can't use Diffinery | | |
| 80 | + | |
| 81 | +A suspended person has a red **suspended** badge, and their status is `disabled` until you unsuspend them (see [Suspend an account](#suspend-an-account)). Each row also has **Suspend** (with a reason), or **Unsuspend** for someone suspended. | |
| 82 | + | |
| 83 | +You can't change your own status ("you cannot change your own status"). | |
| 84 | + | |
| 85 | +Pending accounts also become active without you when someone invites their email address (including with **Add people**), or when the person accepts an invitation sent to their username on the **Waiting for an invite** page. Being invited by username doesn't make the account active by itself. | |
| 86 | + | |
| 87 | +### Disable an account | |
| 88 | + | |
| 89 | +Set the user's status to **Disabled** and click **Save**. Immediately: | |
| 90 | + | |
| 91 | +- every browser session they have ends, so they are signed out everywhere, and passkey sign-in is refused with "Your account is disabled.", | |
| 92 | +- every access token the person has is revoked, | |
| 93 | +- agents they own stop working, because an agent can never do more than its owner, | |
| 94 | +- they get a "Your account was disabled" email. | |
| 95 | + | |
| 96 | +Their repositories, organizations and memberships stay as they are, but they can't be added to organizations or repositories while disabled ("diffy's account is disabled"). Set the status back to **Active** to enable the account; the person gets "Your account was enabled" and signs in with their passkey again. Their old tokens stay revoked, so they need to create new ones. | |
| 97 | + | |
| 98 | +Every status change is recorded in the audit log as `user.active`, `user.pending` or `user.disabled`. | |
| 99 | + | |
| 100 | +### Deleting an account | |
| 101 | + | |
| 102 | +Click **Delete** on the person's row in the **Users** table. A page shows what will go: their repositories (with how many), container images, tokens, passkeys, agents, settings and memberships. Type their username and click **Delete this account**. It needs a sign-in from the last 2 hours, like granting site administration. You see "Deleted the account diffy." and the person gets an email saying a site administrator deleted their account. | |
| 103 | + | |
| 104 | +The page says why, and the button is off, when the person is the only owner of an organization (it names each one), the only site administrator, or has a GitHub import running. Make someone else an owner, or delete the organization, first. | |
| 105 | + | |
| 106 | +Deleting an account is the same as the person deleting it themselves: everything they own goes, what they wrote in other people's repositories is shown as written by **ghost**, and their username can't be used for 90 days. See [Exporting and deleting your account](/docs/accounts/account-data#deleting-your-account). It can't be undone; to keep someone out while you decide, disable the account instead. Each deletion is recorded in the audit log as `account.delete`, with `"via":"admin"`. | |
| 107 | + | |
| 108 | +### Suspend an account | |
| 109 | + | |
| 110 | +Suspending is for abuse: like disabling, it stops the person at once, but it also hides what they made, and unsuspending puts everything back as it was, tokens included. It works as on GitHub Enterprise. | |
| 111 | + | |
| 112 | +In the **Users** table, click **Suspend** next to the person, enter a **Reason** (at most 500 characters; it is emailed to them and kept in the audit log) and click **Suspend diffy**. You can also suspend from a report about the person. Immediately: | |
| 113 | + | |
| 114 | +- every browser session they have ends, and passkey sign-in is refused with "Your account is disabled.", | |
| 115 | +- their access tokens, their agents' tokens and their runners are refused (`401`), but not revoked, | |
| 116 | +- running workflow runs and agent sandboxes in their repositories stop, and no new ones start, as with [Suspend Actions for an account](#kill-switches), | |
| 117 | +- their profile, repositories (on the web, through Git, the API and the container registry) and packages answer `404` to everyone else, | |
| 118 | +- their issues and pull requests answer `404` and are left out of lists and search, and their comments and reviews are left out of every conversation, | |
| 119 | +- they get a "Your account was suspended" email with the reason. | |
| 120 | + | |
| 121 | +Site administrators still see all of it, with a **suspended** badge on the profile; `GET /api/v3/users/{username}` shows them `suspended_at` and `suspension_reason`. Site administrators can't be suspended, and you can't suspend yourself. | |
| 122 | + | |
| 123 | +**Unsuspend** makes the account active again. Their tokens, agents and runners work again, their content is shown again and they get a "Your account is no longer suspended" email. Their browser sessions stay ended, so they sign in again with their passkey. Workflow runs that were stopped don't restart. | |
| 124 | + | |
| 125 | +Suspensions are audited as `user.suspend` (with the reason and what was stopped) and `user.unsuspend`. From the API, with an `admin`-scoped token that isn't limited to repositories (GitHub Enterprise's endpoints): | |
| 126 | + | |
| 127 | +```sh | |
| 128 | +echo '{"reason": "spam in issues, see report 4f2c"}' | diffinery api PUT /users/diffy/suspended --input - | |
| 129 | +diffinery api DELETE /users/diffy/suspended | |
| 130 | +``` | |
| 131 | + | |
| 132 | +Both answer `204`. Anyone else gets `403 site administrators only`. | |
| 133 | + | |
| 134 | +## Early access | |
| 135 | + | |
| 136 | +While the instance is invite-only, people without an invitation can ask for one at `/early-access`, and confirm their address with an emailed link. **Settings → Early access** (`/settings/admin/early-access`) lists the requests: **Confirmed** ones wait for you, with their count next to **Early access** in the sidebar and a reminder on **People and invitations**. **Invite** sends the usual instance invitation (see [Invites](#invites)), **Decline** emails nobody, and **Delete request** removes everything stored about a request. Each decision is in the audit log. See [Early access](/docs/accounts/early-access#for-site-administrators) for the whole page, the API and how the form is protected. | |
| 137 | + | |
| 138 | +## Reports | |
| 139 | + | |
| 140 | +Anyone signed in can report a person, a repository, an issue, a pull request or a comment as abusive, from the **…** menu on it (or on a profile) and **Report**. They pick a reason (spam, harassment or bullying, hateful content, violence or threats, sexual content, malware or phishing, impersonation, or something else) and can add a note of up to 2,000 characters; "Something else" needs the note. The person reported isn't told. One person files at most 20 reports a day, and one open report per thing. | |
| 141 | + | |
| 142 | +**Settings → Reports** (`/settings/admin/reports`) lists the open reports, newest first, with what was reported, the reason, who reported it and when; **Resolved** lists the rest. Open one to see: | |
| 143 | + | |
| 144 | +- a link to what was reported, and the account that wrote it, | |
| 145 | +- the reporter's note, | |
| 146 | +- what was reported as it was then (an issue's title and text, a comment's text), so editing or deleting it afterwards hides nothing, | |
| 147 | +- **Resolve with a note**: say what you did (hid the comment, suspended the account, nothing to do) and click **Resolve**. A resolved report can be reopened with **Reopen**. | |
| 148 | +- **Suspend** the account that wrote it, with a reason. | |
| 149 | + | |
| 150 | +Filing, resolving and reopening are audited as `report.create`, `report.resolve` and `report.reopen`. | |
| 151 | + | |
| 152 | +From the API, people file reports with `POST /api/v3/diffinery/reports`; site administrators read the queue with `GET /api/v3/admin/reports` (`?state=open`, `resolved` or `all`) and `GET /api/v3/admin/reports/{id}`, and resolve with `PATCH /api/v3/admin/reports/{id}` and `{"state": "resolved", "resolution": "..."}` (an `admin`-scoped token). See [REST API](/docs/reference/rest-api#moderation). | |
| 153 | + | |
| 154 | +Other tools for abuse: [blocking](/docs/accounts/blocking-and-reporting) (anyone, for themselves or their organization), [hiding comments](/docs/collaboration/issues#hide-a-comment) (anyone with write access to the repository; site administrators everywhere) and the [kill switches](#kill-switches). | |
| 155 | + | |
| 156 | +## Invites | |
| 157 | + | |
| 158 | +Under **Invite someone to this instance**, enter an **Email address** and click **Send invitation** (the hint says "We email them a link. Opening it lets them pick a username and create a passkey."). The invite isn't tied to an organization or repository: it only lets the person create an account. Diffinery emails the link and also shows it to you under "Invite created for friend@example.com" with a copy button, with the note that it expires in 14 days and works once. Instance invitations can't be revoked. | |
| 159 | + | |
| 160 | +The same works from the CLI (`diffinery invite friend@example.com`, without `--org`) and the API (`POST /api/v3/invitations` with `{"email": "friend@example.com"}`); both are limited to site administrators. Site administrators can also add people to any organization with **Add people** on its **People** tab, and revoke its pending invitations there. See [Invitations](/docs/accounts/invitations) for all kinds of invites. | |
| 161 | + | |
| 162 | +## Sign-in links | |
| 163 | + | |
| 164 | +When someone loses their passkeys, a site administrator can give them a one-time link that adds a new passkey to their existing account. | |
| 165 | + | |
| 166 | +1. In the **Users** table, click **Sign-in link** next to the person. | |
| 167 | +2. Confirm "Create a one-time link that adds a passkey to diffy? Only send it to them." | |
| 168 | +3. The page shows **Sign-in link for diffy** with the link and a copy button. Diffinery has already emailed it to the person ("Your one-time sign-in link"); you can also send it yourself through a channel you trust. | |
| 169 | + | |
| 170 | +The person opens the link, creates a passkey and is signed in. See [Passkeys and sign-in](/docs/accounts/passkeys-and-sign-in#sign-in-link-from-an-administrator) for their side. | |
| 171 | + | |
| 172 | +| Rule | Value | | |
| 173 | +| --- | --- | | |
| 174 | +| Lifetime | 24 hours | | |
| 175 | +| Uses | Once | | |
| 176 | +| Disabled accounts | Refused with "enable this account first"; enable the account, then create the link | | |
| 177 | +| Link format | `https://<host>/signin#<token>`. The token is in the URL fragment, which browsers never send to the server, so it stays out of request logs and `Referer` headers. The page removes it from the address bar. Older `/signin/<token>` links still work. | | |
| 178 | +| Effect | Adds a passkey and signs that browser in; the person's other passkeys stay | | |
| 179 | +| Audit | `signin_link.create` when you create it, `passkey.add` when it is used | | |
| 180 | + | |
| 181 | +Anyone with the link can add a passkey to the account, so send it only to that person. The link also works for a first sign-in, for an account that exists but has no passkey yet. | |
| 182 | + | |
| 183 | +## Kill switches | |
| 184 | + | |
| 185 | +Kill switches stop Actions and agent sandboxes quickly, for abuse, a runaway workflow or a cost problem. There are three, from the narrowest: | |
| 186 | + | |
| 187 | +| Switch | What stops | What keeps working | | |
| 188 | +| --- | --- | --- | | |
| 189 | +| **Suspend Actions for a repository** | Every workflow run of the repository, on hosted and self-hosted runners, and its agent sandboxes (including agent tasks' and reviews') | Everything else in the repository: Git, issues, pull requests | | |
| 190 | +| **Suspend Actions for an account** | The same, for every repository a person or an organization owns, including repositories created or transferred in later | The account's other repositories' Git and collaboration | | |
| 191 | +| **Pause hosted compute** | Hosted Actions jobs and agent sandboxes in every repository on the instance | Self-hosted runners, which cost the instance nothing, and workflow runs themselves | | |
| 192 | + | |
| 193 | +Nobody is exempt, not even site administrators' own accounts and organizations (unlike [account quotas](/docs/reference/limits#account-quotas)). Every change is in the audit log: `actions.suspend` and `actions.unsuspend` with the repository (`owner/name`) or the account's login as the target, `compute.pause` and `compute.unpause`, each with the reason and what was stopped. | |
| 194 | + | |
| 195 | +### While a switch is on | |
| 196 | + | |
| 197 | +While a repository or its owner is suspended: | |
| 198 | + | |
| 199 | +- no workflow run starts: pushes, pull requests, schedules and other events start nothing, and no run is recorded; | |
| 200 | +- **Run workflow** (`workflow_dispatch`), `repository_dispatch` and **Re-run** are refused with `403 Actions are suspended for this repository by a site administrator: <reason>` (or `... for every repository of <account> ...`); | |
| 201 | +- a job that would start fails instead, with "The job was not started: Actions are suspended ...", and a self-hosted runner isn't given a job that was already waiting; | |
| 202 | +- a new sandbox is refused with the same `403`, so agent tasks and reviews fail when they try to start theirs, with that message. | |
| 203 | +- [web terminals](/docs/agents/terminal) can't be opened or attached to, and open ones end within a minute ("A site administrator suspended Actions and sandboxes here"). | |
| 204 | + | |
| 205 | +While hosted compute is paused: | |
| 206 | + | |
| 207 | +- a job on a hosted runner fails without starting: "The job was not started: Hosted runners are paused on this instance by a site administrator: <reason>. Self-hosted runners still work." Jobs of the same run on self-hosted runners run as usual; | |
| 208 | +- a new sandbox is refused with `403 Agent sandboxes are paused on this instance by a site administrator: <reason>` (agent tasks and reviews fail to start theirs), and a sandbox that went to sleep doesn't wake up. | |
| 209 | +- web terminals can't be opened or attached to, and open ones end within a minute. | |
| 210 | + | |
| 211 | +The repository's **Actions** page, its run pages and its **Settings** pages show a red banner with the reason, so the people affected know why. While hosted compute is paused, every **Site administration** page shows "Hosted compute is paused on this instance" at the top, so nobody forgets to turn it back on. | |
| 212 | + | |
| 213 | +### Turning a switch on stops what is running | |
| 214 | + | |
| 215 | +- Suspending a repository or an account cancels each of its unfinished runs ("Cancelled: Actions are suspended ...", including jobs on self-hosted runners) and closes each of its open sandboxes. | |
| 216 | +- Pausing hosted compute fails each running hosted job ("The job was stopped: Hosted runners are paused ...") and closes the open sandboxes, in every repository with hosted compute running (Diffinery finds them from the hosted minutes it is counting). Jobs that wait in the queue fail when they would start. | |
| 217 | + | |
| 218 | +The answer says what was stopped: `stopped` with `repositories`, `runs`, `jobs`, `sandboxes`, and `failed` for repositories that didn't answer (their work still can't start). | |
| 219 | + | |
| 220 | +Turning a switch off only lets new work start again. Nothing that was stopped restarts: re-run the runs you want and start new sandboxes. | |
| 221 | + | |
| 222 | +### From the web | |
| 223 | + | |
| 224 | +**Settings → Kill switches** (`/settings/admin/kill-switches`): | |
| 225 | + | |
| 226 | +- **Hosted compute** shows **running** or **paused**. Enter a **Reason** and click **Pause hosted compute** (you confirm first), or click **Resume hosted compute**. | |
| 227 | +- **Suspend Actions for an account**: the person's or organization's login and a **Reason**, then **Suspend**. | |
| 228 | +- **Suspend Actions for a repository**: `owner/name` and a **Reason**, then **Suspend**. | |
| 229 | +- **Suspended** lists every suspended account and repository with who suspended it, when and why, and a **Lift** button. | |
| 230 | + | |
| 231 | +A reason is required (at most 500 characters): it is shown to the people affected and kept in the audit log. | |
| 232 | + | |
| 233 | +### From the API | |
| 234 | + | |
| 235 | +Site administrators only (`403 only site administrators can ...` for anyone else). Reading needs any token that isn't limited to repositories; changing needs an `admin`-scoped token that isn't limited to repositories, or a browser session. | |
| 236 | + | |
| 237 | +| Method and path | Does | | |
| 238 | +| --- | --- | | |
| 239 | +| `GET /api/v3/admin/repos/{owner}/{repo}/actions-suspension` | `suspended`, `reason`, `suspended_by`, `suspended_at`, and `owner_suspended` when the owner's Actions are suspended | | |
| 240 | +| `PUT /api/v3/admin/repos/{owner}/{repo}/actions-suspension` | Suspend, with `{"reason": "..."}` | | |
| 241 | +| `DELETE /api/v3/admin/repos/{owner}/{repo}/actions-suspension` | Lift | | |
| 242 | +| `GET`, `PUT`, `DELETE /api/v3/admin/accounts/{login}/actions-suspension` | The same for a person or an organization | | |
| 243 | +| `GET`, `PUT`, `DELETE /api/v3/admin/hosted-compute-pause` | `paused`, `reason`, `paused_by` and `paused_at`; pause with `{"reason": "..."}`, resume with `DELETE` | | |
| 244 | + | |
| 245 | +```sh | |
| 246 | +echo '{"reason": "crypto mining, see ticket 123"}' | diffinery api PUT /admin/repos/acme/website/actions-suspension --input - | |
| 247 | +diffinery api DELETE /admin/repos/acme/website/actions-suspension | |
| 248 | +echo '{"reason": "runner image incident"}' | diffinery api PUT /admin/hosted-compute-pause --input - | |
| 249 | +``` | |
| 250 | + | |
| 251 | +## GitHub imports and linked accounts | |
| 252 | + | |
| 253 | +Site administrators have extra reach over [imports from GitHub](/docs/migrate-from-github): | |
| 254 | + | |
| 255 | +- **Import into any account.** A site administrator can start an import into any user's account or any organization, not only their own (agent accounts can't own imports). | |
| 256 | +- **See and cancel any import.** Open `https://diffinery.com/imports/<id>`, or call `GET /api/v3/diffinery/imports/{id}`, for any import, and stop one with **Cancel import** or `POST /api/v3/diffinery/imports/{id}/cancel` (audited as `import.cancel`, with you in `by`). The **Imports** page and `GET /api/v3/diffinery/imports` still list only the imports you started yourself. | |
| 257 | +- **Retrying stays with the starter, except for agents' imports.** A person's import can be retried only by that person, even by a site administrator. An import an agent started can also be retried by any person who may import into the target, which includes site administrators. | |
| 258 | + | |
| 259 | +### Clear a GitHub link | |
| 260 | + | |
| 261 | +An import attributes a GitHub user's issues, pull requests, comments and reviews to the Diffinery account linked to that GitHub user. If a link is wrong, for example after a Diffinery account changed hands, a site administrator can remove it. There is no page for this; use the API with an `admin`-scoped token that isn't limited to repositories (or a browser session): | |
| 262 | + | |
| 263 | +```sh | |
| 264 | +diffinery api DELETE /diffinery/admin/linked-accounts/<link id> | |
| 265 | +``` | |
| 266 | + | |
| 267 | +The link id comes from `GET /api/v3/diffinery/user/linked-accounts`, called by the account that owns the link; Diffinery has no endpoint that lists other people's links. The request answers `204`, or `404 link not found`, and is audited as `account.link_cleared` with the link (`github.com/diffy`) as the target and the Diffinery account's id in the detail. Imports that already ran keep their attribution; later imports treat that GitHub user as a mannequin until someone links the account again. People unlink their own accounts on **Settings → Linked accounts** (`account.unlink_github`). | |
| 268 | + | |
| 269 | +## Account quotas | |
| 270 | + | |
| 271 | +Every person and organization has [quotas](/docs/reference/limits#account-quotas) on hosted minutes per month, hosted jobs running at once, agent sandboxes open at once, repositories and storage. Accounts run by a site administrator have none. The defaults come from the `QUOTA_*` variables in [Configuration](/docs/self-hosting/configuration). There is no page for quotas; use the API: | |
| 272 | + | |
| 273 | +```sh | |
| 274 | +diffinery api GET /admin/accounts/acme/quotas | |
| 275 | +echo '{"hosted_minutes": 6000, "repositories": -1, "storage_bytes": 53687091200}' | diffinery api PATCH /admin/accounts/acme/quotas --input - | |
| 276 | +``` | |
| 277 | + | |
| 278 | +| Request | What it does | Who | | |
| 279 | +| --- | --- | --- | | |
| 280 | +| `GET /api/v3/admin/accounts/{login}/quotas` | The account's quotas and what it uses: `hosted_minutes` (`used`, `limit`), `hosted_jobs` (`running`, `limit`), `sandboxes` (`open`, `limit`), `repositories` (`count`, `limit`), `storage_bytes` (`used`, `limit`, in bytes), with `exempt` and `period_start` (the start of the month, UTC) | site administrator, any token scope | | |
| 281 | +| `PATCH /api/v3/admin/accounts/{login}/quotas` | Changes only the quotas you send: a whole number (`storage_bytes` in bytes, up to 1 PiB), `-1` for unlimited, or `null` to go back to the default. Answers like `GET`. Audited as `account.quotas.set`. | site administrator, with an `admin` token not limited to repositories, or a browser session | | |
| 282 | + | |
| 283 | +`{login}` is a person or an organization; an agent gets `404 no person or organization with that name`, because agents use their owner's quotas. Anyone else gets `403 only site administrators can see and change quotas`. People see their own usage with `GET /api/v3/user/usage`, and organization owners and admins see their organization's with `GET /api/v3/orgs/{org}/usage`. | |
| 284 | + | |
| 285 | +Storage is counted per repository as it changes: each repository's Durable Object keeps a running total of its git objects, Git LFS objects and release assets and reports it to D1 (`repo_storage`), where the owner's repositories and packages are added up. A repository that hasn't reported yet, such as one made before storage was counted, is measured the first time its account's storage is checked (up to 25 per check): its git objects are added up once from its own database, and its Git LFS objects are listed from R2 once. Purging unreachable objects counts the repository again. | |
| 286 | + | |
| 287 | +## Audit log | |
| 288 | + | |
| 289 | +**Settings → Audit log** (`/settings/audit`) shows the 200 most recent security-relevant events: sign-ins, passkeys, tokens, invites, account status changes, repository and access changes, pushes, merges and push protection. Repository and organization settings changed through the REST API or the CLI are recorded too: repository settings, archiving, collaborators, branch protection, Actions secrets and variables (repository, environment and organization), webhooks, a repository's workflow token settings, and deleted Actions artifacts and caches. Imports from GitHub (`import.create`, `import.push`, `import.finish` and the rest) and linked GitHub accounts are recorded too. Settings changes made through the API have `via: api` in their **Detail**. Each row has **When**, **Actor** (and who an agent acted for), **Action**, **Target** and **Detail**. See [Audit log](/docs/security/audit-log) for every action and how to use it. | |
| 290 | + | |
| 291 | ||
| 292 | + | |
| 293 | +**Settings → Email** (`/settings/admin/email`) shows whether the instance can send email and lets you check every template. | |
| 294 | + | |
| 295 | +| Item | What it shows | | |
| 296 | +| --- | --- | | |
| 297 | +| **Sending** | "Cloudflare Email Sending is bound", or "not configured (no EMAIL binding): emails are logged, not sent" | | |
| 298 | +| **Queue** | `diffinery-mail`, or "not bound: emails are dropped" | | |
| 299 | +| **From** | The address emails are sent from: `MAIL_FROM`, or `notifications@<your hostname>` when it is empty (see [Configuration](/docs/self-hosting/configuration)) | | |
| 300 | +| **Bounces and complaints** | Where they come from: the `diffinery-mail-events` queue, and whether the signed endpoint is on (`MAIL_EVENTS_SECRET` set) | | |
| 301 | +| **Last 24 hours** | How many emails were sent, failed and skipped | | |
| 302 | + | |
| 303 | +Buttons: | |
| 304 | + | |
| 305 | +- **Send me a test email** sends one test email to you right away and shows the result. | |
| 306 | +- **Send every template to me** queues one sample of every template to your address, ignoring your notification settings. They show up under **Recent emails** as they are sent. | |
| 307 | + | |
| 308 | +Under **Templates**, pick a template from the menu to preview it in the page. **Open HTML** opens the HTML version in a new tab; **Plain text** shows the text version with its headers (`From`, `Subject`, `X-Diffinery-Reason`, `List-Unsubscribe` and the rest). The previews use sample data such as the repository `acme/website`. | |
| 309 | + | |
| 310 | +**Suppressed addresses** lists every address Diffinery sends nothing to, newest first, with why (`hard-bounce`, `complaint` or `manual`), where that came from and since when. **Remove** sends email to the address again; **Suppress an address** adds one by hand, with an optional note. Both are recorded in the audit log. See [Bounces, complaints and the suppression list](#bounces-complaints-and-the-suppression-list). | |
| 311 | + | |
| 312 | +**Recent emails** lists the last 100 emails sent to anyone, with the time, recipient address, subject, template kind, Cloudflare's message ID, the status (`sent`, `failed` or `skipped`) and the reason when it wasn't sent. The log keeps 90 days. | |
| 313 | + | |
| 314 | +See [Notifications](/docs/accounts/notifications#every-email-diffinery-sends) for when each email is sent. | |
| 315 | + | |
| 316 | +### Email API for administrators | |
| 317 | + | |
| 318 | +| Method and path | Description | Needs | | |
| 319 | +| --- | --- | --- | | |
| 320 | +| `GET /api/v3/diffinery/admin/email` | `sending` and `queue` (whether they are configured), `from` (the sender address: `MAIL_FROM`, or `notifications@<your hostname>` when it is empty), `last_24h` counts (`sent`, `failed`, `skipped`) and the `last` email logged | site administrator, any token scope | | |
| 321 | +| `POST /api/v3/diffinery/admin/emails/samples` | Send every template to yourself now, or only some with `{"only": ["signin", "invite-org"]}`. Returns `to`, `sent`, `total` and each result. | site administrator, with an `admin` token for all repositories or a browser session | | |
| 322 | +| `GET /api/v3/diffinery/admin/email/suppressions` | The suppression list, newest first: `email`, `reason`, `detail`, `source` (`cloudflare`, `webhook`, `send` or `admin`), `created_by`, `created_at` and `owner_can_lift` | site administrator, any token scope | | |
| 323 | +| `PUT /api/v3/diffinery/admin/email/suppressions/{email}` | Suppress an address by hand, with an optional `{"note": "..."}`. Audited as `email.suppression.add`. | site administrator, with an `admin` token or a browser session | | |
| 324 | +| `DELETE /api/v3/diffinery/admin/email/suppressions/{email}` | Remove an address from the list, whatever put it there; `204`, or `404` when it isn't there. Audited as `email.suppression.remove`. | site administrator, with an `admin` token or a browser session | | |
| 325 | +| `GET /api/v3/diffinery/emails?all=1` | Everyone's recent emails (without `all=1`, only yours). Also takes `kind`, `since` and `per_page` (up to 500). | site administrator for `all=1` | | |
| 326 | + | |
| 327 | +The sample IDs are `pr-opened`, `issue-opened`, `comment`, `review-approved`, `review-changes`, `review-requested`, `assigned`, `pushed`, `merged`, `closed`, `reopened`, `run-failed`, `signin`, `passkey-added`, `passkey-removed`, `token-created`, `token-expiring`, `agent-created`, `model-key-added`, `review-limit`, `push-bypassed`, `push-blocked`, `commit-blocked`, `account-disabled`, `account-enabled`, `account-suspended`, `account-unsuspended`, `invite-org`, `invite-repo`, `invite-accepted`, `welcome`, `signin-link`, `email-verify`, `access-request`, `org-member`, `repo-access`, `repo-created`, `repo-deleted`, `repo-transferred` and `test`. | |
| 328 | + | |
| 329 | +### Bounces, complaints and the suppression list | |
| 330 | + | |
| 331 | +Diffinery sends no email at all to an address on the suppression list: not notifications, not security alerts, not sign-in links or account recovery. Such an email shows in **Recent emails** as `skipped` with "address is suppressed: ..." as the reason. An address gets on the list three ways: | |
| 332 | + | |
| 333 | +| Reason | How | | |
| 334 | +| --- | --- | | |
| 335 | +| `hard-bounce` | Email to it bounced permanently (the mailbox or domain doesn't exist, or the receiving server refuses it for good), or Email Sending refused to send to it for good (`E_RECIPIENT_SUPPRESSED`, source `send`). Soft bounces, delays and temporary send errors (rate and daily limits, delivery errors) change nothing. | | |
| 336 | +| `complaint` | Its owner marked an email from Diffinery as spam. | | |
| 337 | +| `manual` | A site administrator added it, under **Suppress an address** or with the API. | | |
| 338 | + | |
| 339 | +A newer event replaces an older one for the same address. Every addition is in the audit log as `email.suppression.add`. | |
| 340 | + | |
| 341 | +**Lifting a suppression.** A site administrator can remove any address (**Remove**, or `DELETE /api/v3/diffinery/admin/email/suppressions/{email}`). People can lift a bounce or complaint on their own address themselves: **Settings → Email** shows that the address is suppressed and why, and **Send a verification email** sends one email to it, past the list. Following its link while signed in to the same account turns email back on (`email.suppression.lift` in the audit log). The link works for 24 hours, and at most 3 can be asked for in an hour. A `manual` suppression can only be removed by an administrator. See [Notifications](/docs/accounts/notifications#when-diffinery-stops-emailing-you). | |
| 342 | + | |
| 343 | +Cloudflare Email Sending keeps a suppression list of its own, for bounces and complaints it sees. When it refuses to send to an address on that list (`E_RECIPIENT_SUPPRESSED`, shown as `failed` in **Recent emails**), Diffinery adds the address to its own list as a `hard-bounce` with source `send`, so later emails are skipped. If you remove such an address from Diffinery's list, remove it in the Cloudflare dashboard too (**Email Service → Email Sending → Suppressions**), or the next email to it puts it back. | |
| 344 | + | |
| 345 | +#### Where bounces and complaints come from | |
| 346 | + | |
| 347 | +Cloudflare Email Sending publishes delivery events (`message.delivered`, `message.bounced`, `message.complained` and others) through [event subscriptions](https://developers.cloudflare.com/email-service/platform/event-subscriptions/) to a queue. Diffinery consumes the `diffinery-mail-events` queue, and **the deploy sets it all up**: `scripts/bootstrap.sh` creates the queue and, when it is missing, an event subscription named `diffinery-mail-events <domain>` with source **Email Sending**, your sending domain (`MAIL_DOMAIN`, or `DIFFINERY_DOMAIN`), and the **bounced** and **complained** events. A subscription you made yourself for that domain and queue is kept; any of those two events it lacks are added. Other events are ignored. | |
| 348 | + | |
| 349 | +The deploy's API token needs **Account → Queues → Edit** for this (the [API reference](https://developers.cloudflare.com/api/resources/queues/subresources/subscriptions/methods/create/) calls it "Queues Write"), and **Zone → Zone → Read** on the zone of your sending domain to look up its zone ID; the domain must be onboarded to Email Sending. Without them the deploy goes on and prints a warning such as `Could not subscribe diffinery-mail-events to Email Sending's bounce and complaint events for example.com. The token needs Account → Queues → Edit ...`, followed by Cloudflare's error. Then either add the permission to the token and deploy again, or subscribe the queue by hand once: | |
| 350 | + | |
| 351 | +1. In the Cloudflare dashboard, open **Storage & databases → Queues** and select `diffinery-mail-events`. | |
| 352 | +2. On the **Subscriptions** tab, click **Subscribe to events**. | |
| 353 | +3. Name the subscription (for example `diffinery-bounces`), pick **Email Sending** as the source and your sending domain (the domain of `MAIL_FROM`), and select at least the **bounced** and **complained** events. Others are ignored. | |
| 354 | +4. Click **Subscribe**. | |
| 355 | + | |
| 356 | +With Wrangler, the same is `wrangler queues subscription create diffinery-mail-events --source email.sending --zone-id <zone id> --domain <sending domain> --events message.bounced,message.complained`. | |
| 357 | + | |
| 358 | +Only Cloudflare can publish to the queue, so its messages need no signature. Until the subscription exists, bounces and complaints reach the list only when Email Sending refuses a send (see above); administrators can still add and remove addresses by hand. | |
| 359 | + | |
| 360 | +**The signed endpoint.** Anything else that can relay bounces and complaints, such as another provider's webhook through a small relay, can POST them to `https://<your host>/-/email/events`. It is off (`404`) until you set the `MAIL_EVENTS_SECRET` Worker secret, and it refuses (`401`) any request without a good signature: | |
| 361 | + | |
| 362 | +- Header `X-Diffinery-Signature: t=<unix seconds>,v1=<signature>`, where the signature is the hex HMAC-SHA256, keyed with `MAIL_EVENTS_SECRET`, of `<t>.<the raw request body>`. Requests more than 5 minutes old (or ahead) are refused, so a captured request can't be replayed later. | |
| 363 | +- The body is JSON: one event, an array of events, or `{"events": [...]}`, at most 1,000 events and 1 MB. Each event has Cloudflare's shape: a `type` ending in `message.bounced` or `message.complained`, and a `payload` with `recipient` and, for bounces, `bounce.type` (`hard` or `soft`) and `bounce.reason`. | |
| 364 | +- The answer is `{"suppressed": <n>, "ignored": <n>}`. Events of any other type, and soft bounces, are ignored. | |
| 365 | + | |
| 366 | +```sh | |
| 367 | +secret=... # the value of MAIL_EVENTS_SECRET | |
| 368 | +body='{"type":"message.bounced","payload":{"recipient":"someone@example.com","bounce":{"type":"hard","reason":"550 5.1.1 User unknown"}}}' | |
| 369 | +t=$(date +%s) | |
| 370 | +sig=$(printf '%s' "$t.$body" | openssl dgst -sha256 -hmac "$secret" -hex | sed 's/^.* //') | |
| 371 | +curl -sS https://diffinery.example/-/email/events -H 'content-type: application/json' \ | |
| 372 | + -H "X-Diffinery-Signature: t=$t,v1=$sig" --data "$body" | |
| 373 | +``` | |
| 374 | + | |
| 375 | +Set the secret with `wrangler secret put MAIL_EVENTS_SECRET -c wrangler.deploy.jsonc` (see [Configuration](/docs/self-hosting/configuration#worker-secrets)). | |
| 376 | + | |
| 377 | +## Security reports | |
| 378 | + | |
| 379 | +`/.well-known/security.txt` names the instance's private report form, `/security/report`, as its first contact (see [Configuration](/docs/self-hosting/configuration#security-contact)). Anyone can send a report there: signed in, or without an account after Cloudflare Turnstile, which the deploy sets up when its API token has **Account → Turnstile → Edit** (see [Turnstile](/docs/self-hosting/configuration#turnstile); without it, only people who are signed in can report). A report has a one-line summary (up to 200 characters), details (plain text, up to 20,000 characters), optionally the affected URL or area, and a reply-to address: required without an account, optional when signed in, when the account's own address is used. Limits: 5 reports a day from one IP address for people who aren't signed in, 50 a day from all of them together, 10 a day per account, and the sign-in rate limit of 20 requests a minute per IP address. | |
| 380 | + | |
| 381 | +Only site administrators can see reports. To anyone else the pages and the API answer `404`, as if they didn't exist. | |
| 382 | + | |
| 383 | +Each new report emails every active site administrator (`security_report`, an email that always sends). The email has the summary and a link to the report, never the details: read those in Diffinery. | |
| 384 | + | |
| 385 | +**Settings → Security reports** (`/settings/admin/security-reports`) lists reports newest first, with when they arrived, the summary, who sent it (a username, or "not signed in") and the state: **New**, **Triaged** or **Resolved**. The **Show** menu filters by state. Click a summary to open the report: its state, when it arrived, the reporter, the reply-to address, the affected URL or area, its reference (the id the reporter was shown) and the details as plain text. Under **Triage**, pick a state, write a note for the other administrators and click **Save**; you see "Report updated." Each change is in the audit log (`security_report.triage`, `security_report.resolve`, or `security_report.reopen` when set back to **New**), with the note. Reply to the reporter from your own mailbox: Diffinery doesn't email them. | |
| 386 | + | |
| 387 | +| Method and path | Description | Needs | | |
| 388 | +| --- | --- | --- | | |
| 389 | +| `GET /api/v3/diffinery/admin/security-reports` | Reports, newest first: `id`, `state`, `summary`, `details`, `area`, `reply_to`, `reporter` (a login, or `null`), `note`, `updated_by`, `updated_at`, `created_at` and `html_url`. Takes `state` (`new`, `triaged` or `resolved`) and `per_page` (default 100, up to 500). | site administrator, a `read` token not limited to repositories, or a browser session | | |
| 390 | +| `GET /api/v3/diffinery/admin/security-reports/{id}` | One report, as in the list | site administrator, a `read` token not limited to repositories, or a browser session | | |
| 391 | +| `PATCH /api/v3/diffinery/admin/security-reports/{id}` | `{"state": "triaged", "note": "..."}`: sets the state (`triaged`, `resolved`, or `new` to reopen) and the note (up to 2,000 characters; leaving it out clears it). Answers with the report. Audited like **Save**, with `via: api`. | site administrator, with an `admin` token not limited to repositories, or a browser session | | |
| 392 | + | |
| 393 | +```sh | |
| 394 | +diffinery api GET '/diffinery/admin/security-reports?state=new' | |
| 395 | +echo '{"state": "resolved", "note": "Fixed in 2026.10.15; reporter credited."}' | diffinery api PATCH /diffinery/admin/security-reports/<id> --input - | |
| 396 | +``` | |
| 397 | + | |
| 398 | +## Status | |
| 399 | + | |
| 400 | +Every instance has a public [status page](/docs/self-hosting/status-page) at `/status`, with JSON at `GET /api/v3/diffinery/status`: each component's status, incidents in progress and those resolved in the last 14 days. Anyone can read it without signing in. | |
| 401 | + | |
| 402 | +**Settings → Status** (`/settings/admin/status`) shows what the page says now, a form to open an incident (a title, the impact, the affected components, a state and the first update), each open incident with a form to post an update or resolve it, and the recent incidents. While an incident isn't resolved, its impact sets the status of each component it names; pausing hosted compute (see [Kill switches](#kill-switches)) puts **Actions** and **Agents and sandboxes** under maintenance without one. Each change is in the audit log (`status_incident.create`, `status_incident.update` or `status_incident.resolve`). Incidents and their updates are public, so keep internal details out of them. | |
| 403 | + | |
| 404 | +| Method and path | Description | Needs | | |
| 405 | +| --- | --- | --- | | |
| 406 | +| `GET /api/v3/diffinery/admin/status/incidents` | Incidents, newest first, with their updates | site administrator, a `read` token not limited to repositories, or a browser session | | |
| 407 | +| `POST /api/v3/diffinery/admin/status/incidents` | `{"title": "...", "impact": "major", "components": ["git"], "state": "investigating", "body": "..."}`: opens an incident | site administrator, with an `admin` token not limited to repositories, or a browser session | | |
| 408 | +| `POST /api/v3/diffinery/admin/status/incidents/{id}/updates` | `{"state": "resolved", "body": "..."}`: posts an update, which resolves the incident when its state is `resolved` | site administrator, with an `admin` token not limited to repositories, or a browser session | | |
| 409 | + | |
| 410 | +To anyone else these endpoints answer `404`. The status page runs in the instance's own Worker, so it is down whenever the whole Worker is: watch `/healthz` with an external uptime monitor too (see [Monitoring from outside](/docs/self-hosting/status-page#monitoring-from-outside)). | |
| 411 | + | |
| 412 | +## Search engines | |
| 413 | + | |
| 414 | +Diffinery tells search engines about public pages that are added, change or go away through [IndexNow](https://www.indexnow.org), so Bing, Yandex, Naver, Seznam, Yep and the other engines that share IndexNow submissions recrawl them within minutes instead of whenever they next visit. Nobody needs to do anything: it runs by itself after every deploy. | |
| 415 | + | |
| 416 | +What it covers: | |
| 417 | + | |
| 418 | +- **After each deploy**, the pages the deploy added, changed or removed: the home page, the product, comparison and company pages (`/features`, `/compare/github`, ...), every docs page, each of those pages' Markdown version (`/index.md`, `/compare/github.md`, `/docs/quickstart.md`, ...), `/llms.txt`, `/llms-full.txt`, and `/early-access` while the instance is invite-only. These are the pages in `/sitemap.xml` plus their Markdown versions. A deploy that changes no page's content submits nothing. | |
| 419 | +- **Every hour**, public repositories pushed since they were last submitted (their page, `/<owner>/<repo>`, as the sitemap lists it). | |
| 420 | +- **Never** a private repository, a repository whose owner is suspended, or any page behind sign-in (settings, notifications, the API, sign-in pages). A repository is only submitted in the same check that found it public, so one made private in the meantime isn't sent, and it is forgotten. Removed site and docs pages are submitted once more, so search engines learn they are gone. | |
| 421 | + | |
| 422 | +How it works: the Worker's five-minute cron notices a new deploy, hashes each page's content and compares the hashes with the ones it last submitted, then sends only the differences in one request (up to 10,000 URLs; more wait for the next run) to `api.indexnow.org`. Engines share what any of them receives, so when `api.indexnow.org` answers `429` or `5xx` or can't be reached, the same request goes to Bing's own endpoint (`www.bing.com/indexnow`), then Yandex's (`yandex.com/indexnow`): IndexNow limits requests per IP address, and a Worker sends from addresses it shares with other Workers. A run on an older version during a deploy's rollout is ignored. Submitting happens after the deploy, in the background, so it can't fail or slow one. If every endpoint answers `429 Too Many Requests`, the pages stay queued for an hour; after another refusal (`400`, `403`, `422`) for 6 hours; if it can't be reached or fails (`5xx`, a timeout), for 15 minutes. IndexNow's `200 OK` and `202 Accepted` (key validation pending) count as submitted. | |
| 423 | + | |
| 424 | +The key that proves submissions come from this site is served at the site root, `/<key>.txt`. It is 32 hex characters derived from the instance's `MASTER_KEY` and `PUBLIC_URL`, so each instance has its own and nothing needs configuring. [Rotating MASTER_KEY](/docs/self-hosting/configuration#rotating-master_key) gives the instance a new key, which the next submission uses. | |
| 425 | + | |
| 426 | +**Settings → Search engines** (`/settings/admin/search-engines`) shows whether IndexNow is on and its key file, how many pages and public repositories it tracks and how many are waiting, and the newest submissions: when, how many URLs, which endpoint answered, its answer and how long it took, any error, and the pages sent. The newest 200 submissions are kept. The same is available over the API: | |
| 427 | + | |
| 428 | +```bash | |
| 429 | +diffinery api GET "/diffinery/admin/indexnow?per_page=10" | |
| 430 | +``` | |
| 431 | + | |
| 432 | +Anyone can see whether it is on and how the last submission went, without the pages, in `GET /api/v3/meta` (`indexnow`). Each submission is also logged in the Worker's logs as `indexnow: <n> URLs, <status> in <ms> ms`. | |
| 433 | + | |
| 434 | +IndexNow is off when `INDEXNOW` is `off`, when `PUBLIC_URL` isn't a public `https` origin (`localhost`, an IP address or `http`, as in local development), or when `MASTER_KEY` isn't set. See [Configuration](/docs/self-hosting/configuration#worker-variables). | |
| 435 | + | |
| 436 | +## Workflow scheduler | |
| 437 | + | |
| 438 | +Scheduled workflows are started by Diffinery's own timer, every 5 minutes, which also catches up checks that came late (see [Scheduled workflows](/docs/actions/events#schedule)). When a scheduled run doesn't start, read the scheduler's state to tell a timer that stopped from a run that failed to start: | |
| 439 | + | |
| 440 | +```bash | |
| 441 | +diffinery api GET /diffinery/admin/scheduler | |
| 442 | +``` | |
| 443 | + | |
| 444 | +```json | |
| 445 | +{ "tick_minutes": 5, "last_tick_at": "2026-10-13T17:30:01.204Z", "covered_until": "2026-10-13T17:30:00.000Z", "next_tick_at": "2026-10-13T17:35:00.000Z" } | |
| 446 | +``` | |
| 447 | + | |
| 448 | +| Field | Meaning | | |
| 449 | +| --- | --- | | |
| 450 | +| `tick_minutes` | Minutes between checks (`SCHEDULE_TICK_MINUTES`, default 5) | | |
| 451 | +| `last_tick_at` | When the last check finished, or `null` before the first | | |
| 452 | +| `covered_until` | The last minute whose schedules have been started | | |
| 453 | +| `next_tick_at` | When the next check is due, or `null` if the timer isn't set (the cron trigger sets it again within 5 minutes) | | |
| 454 | + | |
| 455 | +A `last_tick_at` far behind the time means checks are failing; look in the Worker's logs for `schedule tick failed`. Site administrators only, with any token scope. How it works is in [Architecture](/docs/self-hosting/architecture#workflow-schedules). | |
| 456 | + | |
| 457 | +## Server errors | |
| 458 | + | |
| 459 | +When Diffinery answers a request with a 5xx status, it keeps a record of the error. The answer's JSON includes a `request_id`, which also appears in the `x-request-id` header, and an error page shows it as **Request ID**. Look the request ID up under **Settings → Server errors**, or over the API: | |
| 460 | + | |
| 461 | +```bash | |
| 462 | +diffinery api GET /diffinery/admin/errors/<request id> | |
| 463 | +diffinery api GET "/diffinery/admin/errors?per_page=20" | |
| 464 | +``` | |
| 465 | + | |
| 466 | +Each record has the request's method, `route` (the matched route pattern), `path`, `status`, `message` and `stack`. Only the newest 500 errors are kept. Records never include request bodies, query strings or headers. Tokens that appear in a message are masked, and a path that carries a secret, such as a sign-in link, is stored as its route pattern instead. On Cloudflare, the request ID is the request's Ray ID, so the same request can also be found in Cloudflare's logs. | |
| 467 | + | |
| 468 | +## Administration from the CLI | |
| 469 | + | |
| 470 | +The CLI covers invites and, through `diffinery api`, any API endpoint: | |
| 471 | + | |
| 472 | +```sh | |
| 473 | +curl -fsSL https://diffinery.com/install | bash | |
| 474 | +diffinery login | |
| 475 | + | |
| 476 | +# Invite someone to the instance (site administrators only) | |
| 477 | +diffinery invite friend@example.com | |
| 478 | + | |
| 479 | +# Invite someone to an organization as an admin | |
| 480 | +diffinery invite friend@example.com --org acme --role admin | |
| 481 | + | |
| 482 | +# Email delivery status | |
| 483 | +diffinery api GET /diffinery/admin/email | |
| 484 | + | |
| 485 | +# Send two sample emails to yourself (needs an admin-scoped token) | |
| 486 | +echo '{"only": ["signin", "test"]}' | diffinery api POST /diffinery/admin/emails/samples --input - | |
| 487 | + | |
| 488 | +# Everyone's recent emails, with their status | |
| 489 | +diffinery api GET /diffinery/emails -f all=1 -f per_page=100 | |
| 490 | +``` | |
| 491 | + | |
| 492 | +`diffinery api METHOD PATH` sends an authenticated request; `PATH` is relative to `/api/v3`. `-f key=value` adds a field (a query parameter for `GET`), and `--input FILE` (or `-` for standard input) sends a JSON body. The CLI also manages tokens, agents and organization secrets; see [CLI reference](/docs/reference/cli). | |
| 493 | + | |
| 494 | +Approving access requests, changing account status and creating sign-in links are only available on the **People and invitations** page. | |
| 495 | + | |
| 496 | +## Troubleshooting | |
| 497 | + | |
| 498 | +| Message | Cause and fix | | |
| 499 | +| --- | --- | | |
| 500 | +| `403 site administrators only` | Your account isn't a site administrator. | | |
| 501 | +| `403 site admin only` from `DELETE /api/v3/diffinery/admin/linked-accounts/{id}` | Your account isn't a site administrator. | | |
| 502 | +| `404 link not found` | No GitHub link has that id. Ask the account's owner for the id from `GET /api/v3/diffinery/user/linked-accounts`. | | |
| 503 | +| "you cannot change your own status" | Ask another site administrator. | | |
| 504 | +| `422 ... site administrators can't be suspended` | Site administrators can't be suspended. | | |
| 505 | +| `422 give a reason for the suspension` | Suspending needs a reason. | | |
| 506 | +| `403 Actions are suspended ... by a site administrator` | A kill switch is on for the repository or its owner. Lift it under **Settings → Kill switches**. | | |
| 507 | +| "Hosted runners are paused on this instance" | Hosted compute is paused. Resume it under **Settings → Kill switches**, or run the job on a self-hosted runner. | | |
| 508 | +| `422 give a reason` | Kill switches need a reason. | | |
| 509 | +| "enable this account first" | Sign-in links can't be created for disabled accounts. Set the status to `active`, then create the link. | | |
| 510 | +| "enter a valid email address" | The invite form needs an address like `friend@example.com`. | | |
| 511 | +| `403 only site admins can invite people without an organization` | Instance invites from the CLI or API need a site administrator; others must pass `--org`. | | |
| 512 | +| **Sending** says "not configured" | The Worker has no `send_email` binding, so emails are logged as skipped. See [Configuration](/docs/self-hosting/configuration). | | |
| 513 | +| Emails show as `failed` | The **Recent emails** detail has Cloudflare's error. Check the sending domain's DNS records and the queue. | | |
| 514 | +| Emails show as `skipped` with "address is suppressed" | The address bounced, reported spam or was suppressed by hand. See [Bounces, complaints and the suppression list](#bounces-complaints-and-the-suppression-list). | | |
| 515 | +| `E_RECIPIENT_SUPPRESSED` | The address is on Cloudflare's own suppression list. Remove it in the Cloudflare dashboard. | | |
| 516 | +| `401 missing or bad signature` from `/-/email/events` | The `X-Diffinery-Signature` header is missing, signed with another secret, or more than 5 minutes old. | |
addedapps/diffinery/docs/agents/docs-for-agents.md+97−0View file
| @@ -1,0 +1,97 @@ | ||
| 1 | +--- | |
| 2 | +title: Docs for AI agents | |
| 3 | +description: How AI agents and tools can fetch these docs as Markdown, llms.txt, llms-full.txt, llms-small.txt, per-section files, OpenAPI and over MCP. | |
| 4 | +updated: 2026-10-25 | |
| 5 | +--- | |
| 6 | + | |
| 7 | +These docs are written to be read by AI agents as well as people. Every page is available as plain Markdown, the whole set is available as one file, the REST API is described in OpenAPI, and agents connected over MCP can search and read the docs with tools. The URLs below need no account; the MCP tools need a Diffinery token, like every MCP call. | |
| 8 | + | |
| 9 | +## Formats | |
| 10 | + | |
| 11 | +| What | URL | Content type | | |
| 12 | +| --- | --- | --- | | |
| 13 | +| Index of every page, the site's and the docs' (the [llms.txt](https://llmstxt.org) convention) | `https://diffinery.com/llms.txt` | `text/plain` | | |
| 14 | +| Every page, the site's and the docs', in one Markdown file | `https://diffinery.com/llms-full.txt` | `text/plain` | | |
| 15 | +| The site's pages (what Diffinery is, comparisons, performance, FAQ) and the essential docs pages (overview, quickstart, concepts, the guides, compatibility with GitHub Actions and connecting AI apps) in one smaller file | `https://diffinery.com/llms-small.txt` | `text/plain` | | |
| 16 | +| One docs section: its index, and all its pages in one file | `https://diffinery.com/docs/<section>/llms.txt` and `/docs/<section>/llms-full.txt`, for example `/docs/actions/llms-full.txt` | `text/plain` | | |
| 17 | +| One page as Markdown | `https://diffinery.com/docs/<page>.md`, for example `/docs/actions/workflow-syntax.md` | `text/markdown` | | |
| 18 | +| The overview as Markdown | `https://diffinery.com/docs/index.md` | `text/markdown` | | |
| 19 | +| The site's other pages as Markdown: the home page, product pages and comparisons with other products | `https://diffinery.com/index.md`, and `.md` added to any page, for example `/compare/github.md` | `text/markdown` | | |
| 20 | +| REST API as OpenAPI 3.1 | `https://diffinery.com/docs/api/openapi.json` | `application/json` | | |
| 21 | +| Search index used by the docs search box | `https://diffinery.com/docs/search.json` | `application/json` | | |
| 22 | + | |
| 23 | +The section names are the docs sections in lowercase with hyphens: `get-started`, `guides`, `connect-ai-apps`, `accounts`, `repositories`, `collaboration`, `actions`, `packages`, `agents-and-ai`, `security`, `administration` and `reference`. `llms.txt` links each section's file under its heading. | |
| 24 | + | |
| 25 | +`/docs/llms.txt` and `/docs/llms-full.txt` redirect to the root files. These responses send `Access-Control-Allow-Origin: *`, so browser-based tools can fetch them too. | |
| 26 | + | |
| 27 | +A `.md` URL for a page that doesn't exist returns `404` with a short Markdown body that points to `llms.txt`. | |
| 28 | + | |
| 29 | +## Markdown pages | |
| 30 | + | |
| 31 | +Each Markdown page starts with the page title as a level-1 heading, its one-line description as a blockquote and its source URL, followed by the page body: | |
| 32 | + | |
| 33 | +```text | |
| 34 | +# Merge queue | |
| 35 | + | |
| 36 | +> Merge pull requests in order, each tested on top of the base branch and the pull requests ahead of it, with merge_group workflows and required checks. | |
| 37 | + | |
| 38 | +Source: https://diffinery.com/docs/collaboration/merge-queue | |
| 39 | + | |
| 40 | +... | |
| 41 | +``` | |
| 42 | + | |
| 43 | +Links inside Markdown pages are absolute (`https://diffinery.com/docs/...`), so they work outside the site. Add `.md` to any of them to fetch that page as Markdown too. | |
| 44 | + | |
| 45 | +### Content negotiation | |
| 46 | + | |
| 47 | +A request for an HTML docs URL with `Accept: text/markdown` gets the Markdown version instead, so an agent can fetch the URL it was given: | |
| 48 | + | |
| 49 | +```sh | |
| 50 | +curl -H "Accept: text/markdown" https://diffinery.com/docs/collaboration/pull-requests | |
| 51 | +``` | |
| 52 | + | |
| 53 | +HTML pages also point at their Markdown version with `<link rel="alternate" type="text/markdown">` in the page head and a `Link` response header. | |
| 54 | + | |
| 55 | +### Copy page | |
| 56 | + | |
| 57 | +Every page in the browser has **View as Markdown** and **Copy page** buttons. **Copy page** puts the page's Markdown on your clipboard, ready to paste into a chat with an AI assistant. | |
| 58 | + | |
| 59 | +## Which format to use | |
| 60 | + | |
| 61 | +- **Answering one question:** fetch [llms.txt](/llms.txt), pick the page from its one-line descriptions, then fetch that page's `.md` URL. This keeps the context small. | |
| 62 | +- **Explaining or comparing Diffinery:** load [llms-small.txt](/llms-small.txt). It holds the product, comparison, FAQ and company pages with their dates and sources, plus the essential docs, in a fraction of the size of `llms-full.txt`. | |
| 63 | +- **Working on one area for a while (for example, porting CI):** load that section's file, such as [the Actions section](/docs/actions/llms-full.txt). | |
| 64 | +- **Working on Diffinery broadly:** load [llms-full.txt](/llms-full.txt) once. It holds every page in reading order, separated by `---`, including the generated REST API reference, so it's large. | |
| 65 | +- **Calling the REST API:** read the [OpenAPI document](/docs/api/openapi.json) or the [REST API reference](/docs/api). Both are generated from the server's router, so every endpoint that exists is listed. Use the [REST API guide](/docs/reference/rest-api) for authentication, errors and pagination. | |
| 66 | +- **Using MCP:** the [MCP tool reference](/docs/reference/mcp-tools) is generated from the server's tool list and matches `tools/list` exactly. | |
| 67 | + | |
| 68 | +## Searching and reading docs over MCP | |
| 69 | + | |
| 70 | +An agent connected to the Diffinery MCP server (`https://diffinery.com/mcp`, with `Authorization: Bearer dfy_...`) has two read-only tools for the docs. They work with a token of any scope: | |
| 71 | + | |
| 72 | +| Tool | Arguments | Returns | | |
| 73 | +| --- | --- | --- | | |
| 74 | +| `search_docs` | `query` | Up to 8 matching pages, docs and site pages (comparisons, performance, FAQ) alike, each with `page`, `title`, `section`, the matching `heading`, `url`, `markdown_url` and a `snippet`. | | |
| 75 | +| `read_docs` | `page`: a slug like `actions/workflow-syntax`, `index`, a docs URL (a `.md` suffix or `#anchor` is ignored), or a site page's path or URL like `/compare/github` | `page`, `title` and `markdown`: the page in the same format as its `.md` URL. An unknown page returns an error that lists every slug and site page. | | |
| 76 | + | |
| 77 | +```json | |
| 78 | +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_docs","arguments":{"query":"workflow_dispatch inputs"}}} | |
| 79 | +``` | |
| 80 | + | |
| 81 | +Every term in the query must appear in a page for it to match. Title matches rank highest, then section headings, then body text. See [MCP](/docs/agents/mcp) for connecting a client. | |
| 82 | + | |
| 83 | +## Pointing a coding agent at the docs | |
| 84 | + | |
| 85 | +Add a line like this to your agent's instructions (`CLAUDE.md`, `AGENTS.md`, `.cursorrules` or similar) in repositories hosted on Diffinery: | |
| 86 | + | |
| 87 | +```text | |
| 88 | +This repository is hosted on Diffinery (https://diffinery.com), a GitHub-compatible forge. | |
| 89 | +Docs index: https://diffinery.com/llms.txt (append .md to any docs URL for Markdown). | |
| 90 | +CI uses GitHub Actions syntax; check https://diffinery.com/docs/actions/compatibility.md | |
| 91 | +before using a GitHub-only feature. gh works after `diffinery auth setup-gh`; the | |
| 92 | +diffinery CLI and the MCP server at https://diffinery.com/mcp work too. | |
| 93 | +``` | |
| 94 | + | |
| 95 | +## Staying current | |
| 96 | + | |
| 97 | +The docs ship with the server: each deploy of Diffinery serves the docs for exactly that version, and the API, CLI and MCP references are generated from the code (the API and MCP references at runtime, the CLI reference when the server is built), so they can't fall behind it. The Markdown, `llms.txt`, `llms-full.txt`, `llms-small.txt`, per-section, OpenAPI and search index responses are cached for at most five minutes. CI checks on every change that each page builds, that every link and anchor resolves, and that every endpoint and tool appears in the references. |
addedapps/diffinery/docs/agents/github-cli.md+73−0View file
| @@ -1,0 +1,73 @@ | ||
| 1 | +--- | |
| 2 | +title: GitHub CLI and Claude Code | |
| 3 | +description: Use gh with Diffinery repositories, and get Claude Code's pull request badge and CI status for them, by signing gh in to Diffinery once. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +The GitHub CLI (`gh`) works with Diffinery the way it works with a GitHub Enterprise Server: in a clone of a Diffinery repository, `gh pr create`, `gh pr view`, `gh pr checks`, `gh pr merge` and the other pull request commands talk to Diffinery. Claude Code uses the same setup to show the pull request of the branch you're on and its CI, because it finds pull requests through gh's sign-in. Both go through Diffinery's [GraphQL API](/docs/reference/graphql-api) and [REST API](/docs/reference/rest-api). | |
| 7 | + | |
| 8 | +## Set it up | |
| 9 | + | |
| 10 | +You need a recent `gh` (Diffinery is tested with 2.89) and a Diffinery token. With the [Diffinery CLI](/docs/accounts/cli-sign-in) signed in, one command signs gh in too: | |
| 11 | + | |
| 12 | +```sh | |
| 13 | +diffinery login | |
| 14 | +diffinery auth setup-gh | |
| 15 | +``` | |
| 16 | + | |
| 17 | +`diffinery auth setup-gh` runs `gh auth login --hostname diffinery.com --with-token --git-protocol https` and hands gh your token on its standard input, so the token never shows in your shell history or on screen. gh keeps it in its own credential store. | |
| 18 | + | |
| 19 | +Without the Diffinery CLI, create a token under **Settings → Access tokens** (`write` scope, so gh can open and merge pull requests), save it to a file, and give it to gh the same way: | |
| 20 | + | |
| 21 | +```sh | |
| 22 | +gh auth login --hostname diffinery.com --with-token --git-protocol https < ~/diffinery-token.txt | |
| 23 | +``` | |
| 24 | + | |
| 25 | +In CI or a container, set the host and token in the environment instead. gh and Claude Code both read them: | |
| 26 | + | |
| 27 | +```sh | |
| 28 | +export GH_HOST=diffinery.com | |
| 29 | +export GH_ENTERPRISE_TOKEN=... # from your secret store | |
| 30 | +``` | |
| 31 | + | |
| 32 | +Check it with `gh api user --hostname diffinery.com --jq .login`, which prints your login. | |
| 33 | + | |
| 34 | +## Use gh | |
| 35 | + | |
| 36 | +In a clone whose `origin` is on Diffinery, gh picks the host from the remote: | |
| 37 | + | |
| 38 | +```sh | |
| 39 | +gh pr create --title "Fix the parser" --body "..." --draft | |
| 40 | +gh pr view --web | |
| 41 | +gh pr checks --watch | |
| 42 | +gh pr ready | |
| 43 | +gh pr merge --auto --squash | |
| 44 | +``` | |
| 45 | + | |
| 46 | +| Works | Commands | | |
| 47 | +| --- | --- | | |
| 48 | +| Reading | `gh pr view` (with `--json`), `gh pr list`, `gh pr status`, `gh pr checks`, `gh pr diff`, `gh repo view`, `gh api` (REST and `gh api graphql`) | | |
| 49 | +| Changing | `gh pr create` (with `--draft`, `--reviewer`, `--assignee`, `--label`), `gh pr edit`, `gh pr ready`, `gh pr comment`, `gh pr close`, `gh pr reopen`, `gh pr merge` (`--merge`, `--squash`, `--rebase`, `--auto`, `--disable-auto`) | | |
| 50 | +| Not yet | Team reviewers, projects, and commands outside pull requests that need GraphQL, such as `gh issue` and `gh release` (use `gh api` with the REST paths) | | |
| 51 | + | |
| 52 | +gh changes things with your token's permissions and Diffinery's rules: branch protection, required checks and reviews, and the [agent guardrails](/docs/agents/overview) apply as they do on the web and over REST. `gh pr merge --auto` turns on Diffinery's auto-merge, which merges once the required checks pass. When nothing blocks the merge, gh merges at once. | |
| 53 | + | |
| 54 | +## Claude Code's pull request badge | |
| 55 | + | |
| 56 | +Claude Code shows the pull request for the branch a session is on, and follows it as it changes: | |
| 57 | + | |
| 58 | +- **In the terminal**, the footer shows `PR #12` for the current branch's open pull request. | |
| 59 | +- **In the Claude desktop app**, the session's pull request chip shows the pull request and its CI. Claude Code reports the branch's pull request when a session starts, and a pull request it opens with `gh pr create` as soon as gh prints its URL. | |
| 60 | + | |
| 61 | +There's nothing to set up beyond signing gh in. Claude Code treats any host other than github.com as GitHub Enterprise: it reads the remote of the folder it's in, gets the token from `GH_ENTERPRISE_TOKEN` (when `GH_HOST` names the host) or from `gh auth token --hostname diffinery.com`, and asks Diffinery's REST and GraphQL APIs for the pull request, its review decision and its CI counts. In the desktop app, the folder has to be one you've trusted. | |
| 62 | + | |
| 63 | +Sessions in Claude Code on the web (claude.ai/code) and project threads run in Anthropic's cloud and get their pull request cards from Anthropic's GitHub integration, which doesn't reach Diffinery. Those sessions can still use gh and the [MCP server](/docs/agents/mcp) to open pull requests and follow CI, and the `diffinery-ci` plugin, which the repository's [Agent setup](/docs/connect/cloud-agents) script adds, tells Claude when CI on its pushed commit passes or fails. For a live view of every check in the terminal, the desktop app and VS Code, see [CI status in Claude](/docs/connect/ci-status-in-claude). | |
| 64 | + | |
| 65 | +## Troubleshooting | |
| 66 | + | |
| 67 | +| Symptom | Cause and fix | | |
| 68 | +| --- | --- | | |
| 69 | +| gh asks you to run `gh auth login` in a Diffinery clone | gh has no token for the host. Run `diffinery auth setup-gh`, or check that `GH_HOST` matches the remote's host. | | |
| 70 | +| `HTTP 401` from gh | The token expired or was revoked. Create a new one and sign gh in again. | | |
| 71 | +| `gh pr create` or `gh pr merge` fails with a permission error | The token has the `read` scope only, or you need write access to the repository. | | |
| 72 | +| No badge in the terminal | The branch has no open pull request, or gh isn't signed in to the host. `gh pr view` in the same folder shows which. | | |
| 73 | +| `gh auth status` shows most of an older token | gh hides a token only after its last `_`. Tokens Diffinery creates now have no `_` after `dfy_`; if yours does, create a new one and sign gh in again. | |
addedapps/diffinery/docs/agents/mcp.md+537−0View file
| @@ -1,0 +1,537 @@ | ||
| 1 | +--- | |
| 2 | +title: MCP server | |
| 3 | +description: Connect AI agents to Diffinery over the Model Context Protocol at https://diffinery.com/mcp, with client setup, the tool list, permissions and example calls. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +Diffinery runs a Model Context Protocol (MCP) server at `https://diffinery.com/mcp`. An MCP client such as Claude Code, Cursor or VS Code can use it to read code, commit changes, open and review pull requests, follow CI, publish releases, run commands in sandboxes, import repositories from GitHub and look things up in these docs, all without a git client. Every tool goes through the same permission checks, branch protection and agent guardrails as git and the REST API. | |
| 7 | + | |
| 8 | +## Endpoint and transport | |
| 9 | + | |
| 10 | +| Property | Value | | |
| 11 | +| --- | --- | | |
| 12 | +| URL | `https://diffinery.com/mcp`; on a [self-hosted Diffinery](/docs/self-hosting), `/mcp` on your instance, such as `https://git.example.com/mcp` | | |
| 13 | +| Transport | Streamable HTTP. Send JSON-RPC 2.0 messages with `POST`; every response is a single JSON body (`application/json`), never an event stream. | | |
| 14 | +| Sessions | None. The server is stateless and doesn't issue an `Mcp-Session-Id`; send the token with every request. | | |
| 15 | +| Batches | A JSON array of requests gets a JSON array of responses. | | |
| 16 | +| Notifications | Requests without an `id` (such as `notifications/initialized`) get `202 Accepted` with an empty body. | | |
| 17 | +| `GET /mcp` | `405` with `{"message": "Diffinery MCP: POST JSON-RPC here with Authorization: Bearer <token>"}`. There is no server-to-client stream. | | |
| 18 | +| Protocol versions | `2025-06-18` (default), `2025-03-26`, `2024-11-05`. `initialize` answers with the version you asked for when it is supported, otherwise `2025-06-18`. | | |
| 19 | +| Capabilities | `tools` only (`listChanged: false`). No resources or prompts. | | |
| 20 | +| Methods | `initialize`, `ping`, `tools/list`, `tools/call`, and the notifications `notifications/initialized` and `notifications/cancelled` | | |
| 21 | +| Server info | `{"name": "diffinery", "title": "Diffinery", "version": "0.1.0"}`, plus short usage `instructions` for the model (how to read code, commit, open pull requests, watch CI, use sandboxes and search the docs) | | |
| 22 | + | |
| 23 | +## Authentication | |
| 24 | + | |
| 25 | +There are two ways to authenticate, and every tool call goes through the same checks either way. | |
| 26 | + | |
| 27 | +**Sign in through the browser (OAuth).** Clients that support MCP's OAuth sign-in (claude.ai connectors, Claude Code, Codex, Cursor, VS Code) need nothing but the URL. On their first request they get a `401` whose header says where to sign in: | |
| 28 | + | |
| 29 | +```http | |
| 30 | +WWW-Authenticate: Bearer realm="Diffinery", resource_metadata="https://diffinery.com/.well-known/oauth-protected-resource/mcp" | |
| 31 | +``` | |
| 32 | + | |
| 33 | +The client then opens Diffinery in your browser, where you choose whom it acts as (you, or one of your [agents](/docs/agents/overview)) and its access, and connect it. It gets tokens that last 8 hours and renew themselves; the connection is listed under **Settings → Access tokens → Connected apps**, where you can disconnect it. See [How sign-in works](/docs/connect/how-sign-in-works) for the details. | |
| 34 | + | |
| 35 | +**A token in the header.** For scripts, CI and cloud sessions where nobody can click through a browser, send a Diffinery token: | |
| 36 | + | |
| 37 | +```http | |
| 38 | +Authorization: Bearer dfy_... | |
| 39 | +``` | |
| 40 | + | |
| 41 | +`token dfy_...` and HTTP Basic auth with the token as the password also work. Use an agent's token for an AI agent (see [Agents](/docs/agents/overview)), or your own personal token (see [Tokens](/docs/accounts/tokens)). | |
| 42 | + | |
| 43 | +Either way, the token's scope limits what the tools can do: every tool that isn't read-only needs the `write` or `admin` scope, so a `read` token can only use read-only tools (see [Token scope and write tools](#token-scope-and-write-tools)). | |
| 44 | + | |
| 45 | +| Response | Meaning | | |
| 46 | +| --- | --- | | |
| 47 | +| `401 {"message": "sign in required"}` | No `Authorization` header. The `WWW-Authenticate` header points at the OAuth metadata. | | |
| 48 | +| `401 {"message": "invalid, expired or revoked token"}` | The token is wrong, expired or revoked, or its account (or agent) is disabled. `WWW-Authenticate` adds `error="invalid_token"`, so an OAuth client renews its token or signs in again. | | |
| 49 | +| `401 {"message": "unrecognised token; create one under Settings → Access tokens"}` | The header doesn't hold a `dfy_` token. | | |
| 50 | + | |
| 51 | +## Connect your client | |
| 52 | + | |
| 53 | +For Claude, Claude Code, Codex and other apps with browser sign-in, [Connect AI apps](/docs/connect) has step-by-step guides: a connector or a plugin, and a browser sign-in. The snippets below use a token in the header instead, for clients without OAuth and for headless use. Create the token in the web UI: for an agent, in **Settings → Agents**; for yourself, in **Settings → Access tokens**. Tokens can't create tokens. Keep it out of files you commit; most clients below can read it from an environment variable. | |
| 54 | + | |
| 55 | +### Claude Code | |
| 56 | + | |
| 57 | +With a browser on hand, add the server without a header and sign in when Claude Code asks (or run `/mcp`): | |
| 58 | + | |
| 59 | +```sh | |
| 60 | +claude mcp add --transport http diffinery https://diffinery.com/mcp | |
| 61 | +``` | |
| 62 | + | |
| 63 | +With a token instead: | |
| 64 | + | |
| 65 | +```sh | |
| 66 | +claude mcp add --transport http diffinery https://diffinery.com/mcp \ | |
| 67 | + --header "Authorization: Bearer dfy_..." | |
| 68 | +``` | |
| 69 | + | |
| 70 | +To share the server with a project without sharing the token, put this in the project's `.mcp.json` and set `DIFFINERY_TOKEN` in each developer's environment: | |
| 71 | + | |
| 72 | +```json | |
| 73 | +{ | |
| 74 | + "mcpServers": { | |
| 75 | + "diffinery": { | |
| 76 | + "type": "http", | |
| 77 | + "url": "https://diffinery.com/mcp", | |
| 78 | + "headers": { "Authorization": "Bearer ${DIFFINERY_TOKEN}" } | |
| 79 | + } | |
| 80 | + } | |
| 81 | +} | |
| 82 | +``` | |
| 83 | + | |
| 84 | +The `diffinery` CLI prints exactly this snippet with `diffinery mcp-config` (add `--show-token` to embed the saved token instead of `${DIFFINERY_TOKEN}`). Run `/mcp` inside Claude Code to check that the server is connected. | |
| 85 | + | |
| 86 | +### Cursor | |
| 87 | + | |
| 88 | +Add to `.cursor/mcp.json` in the project, or `~/.cursor/mcp.json` for all projects: | |
| 89 | + | |
| 90 | +```json | |
| 91 | +{ | |
| 92 | + "mcpServers": { | |
| 93 | + "diffinery": { | |
| 94 | + "url": "https://diffinery.com/mcp", | |
| 95 | + "headers": { "Authorization": "Bearer dfy_..." } | |
| 96 | + } | |
| 97 | + } | |
| 98 | +} | |
| 99 | +``` | |
| 100 | + | |
| 101 | +### VS Code | |
| 102 | + | |
| 103 | +Add to `.vscode/mcp.json`. VS Code prompts for the token and stores it securely: | |
| 104 | + | |
| 105 | +```json | |
| 106 | +{ | |
| 107 | + "inputs": [ | |
| 108 | + { "type": "promptString", "id": "diffinery-token", "description": "Diffinery token (dfy_...)", "password": true } | |
| 109 | + ], | |
| 110 | + "servers": { | |
| 111 | + "diffinery": { | |
| 112 | + "type": "http", | |
| 113 | + "url": "https://diffinery.com/mcp", | |
| 114 | + "headers": { "Authorization": "Bearer ${input:diffinery-token}" } | |
| 115 | + } | |
| 116 | + } | |
| 117 | +} | |
| 118 | +``` | |
| 119 | + | |
| 120 | +### Claude Desktop and other clients that start local servers | |
| 121 | + | |
| 122 | +Clients whose configuration only launches local (stdio) servers can reach Diffinery through a bridge such as `mcp-remote`, which forwards to the remote URL with your header: | |
| 123 | + | |
| 124 | +```json | |
| 125 | +{ | |
| 126 | + "mcpServers": { | |
| 127 | + "diffinery": { | |
| 128 | + "command": "npx", | |
| 129 | + "args": ["-y", "mcp-remote", "https://diffinery.com/mcp", "--header", "Authorization:${AUTH_HEADER}"], | |
| 130 | + "env": { "AUTH_HEADER": "Bearer dfy_..." } | |
| 131 | + } | |
| 132 | + } | |
| 133 | +} | |
| 134 | +``` | |
| 135 | + | |
| 136 | +### Any other client | |
| 137 | + | |
| 138 | +Point an MCP client that supports the Streamable HTTP transport at `https://diffinery.com/mcp` and configure a custom `Authorization: Bearer dfy_...` header. The usual JSON form is: | |
| 139 | + | |
| 140 | +```json | |
| 141 | +{ "mcpServers": { "diffinery": { "type": "http", "url": "https://diffinery.com/mcp", "headers": { "Authorization": "Bearer dfy_..." } } } } | |
| 142 | +``` | |
| 143 | + | |
| 144 | +## Tools | |
| 145 | + | |
| 146 | +Every tool that works on a repository takes `owner` and `repo`. A repository that was transferred or renamed is also found under its old owner and name, for anyone who can read it, so an agent configured with the old name keeps working. Tools marked read-only have the MCP annotation `readOnlyHint: true`, so clients can run them without asking. Every tool marked **No** needs a token with the `write` or `admin` scope, whatever its minimum role. The full input schema of each tool is on [MCP tools reference](/docs/reference/mcp-tools), and `tools/list` returns it at runtime. | |
| 147 | + | |
| 148 | +### Content written by others | |
| 149 | + | |
| 150 | +Repository files, branch names, issue and pull request text, comments, workflow logs and sandbox output can be written by anyone who can push or comment, including on a public repository. Someone can write text there meant to steer your agent. Results from tools that return such content (`list_repos`, `list_tree`, `get_file`, `list_branches`, `compare`, `list_pull_requests`, `get_pull_request`, `list_issues`, `get_issue`, `list_workflow_runs`, `get_workflow_run`, `get_job_logs`, `sandbox_exec`, `sandbox_read_file`, `get_import` and `list_imports`) end with a second text block. It tells the model to treat the result as data and not to follow instructions inside it. These tools also carry the `openWorldHint` annotation in `tools/list`, and the server's `instructions` say the same. This lowers the risk but can't remove it, so give an agent a token with only the scopes and repositories it needs. See [Personal access tokens](/docs/accounts/tokens). | |
| 151 | + | |
| 152 | +### Account and repositories | |
| 153 | + | |
| 154 | +| Tool | Read-only | What it does | Minimum role | | |
| 155 | +| --- | --- | --- | --- | | |
| 156 | +| `whoami` | Yes | The account the token acts as (`login`, `kind`) and, for an agent, `on_behalf_of` | Any token | | |
| 157 | +| `today` | Yes | Your morning view: pull requests that need you, your agents' open pull requests and where each stands, and what merged, was released or failed in the last 24 hours. See [Today](/docs/agents/today) | Any token | | |
| 158 | +| `agent_tasks` | Yes | Agent tasks: with `owner` and `repo` that repository's, without them the ones you started (an agent: the ones it runs). See [Agent tasks](/docs/agents/tasks) | Write on the repository; any token for your own | | |
| 159 | +| `agent_task` | Yes | One agent task (`owner`, `repo`, `number`) with its runs, pull request and transcript after `after` | Write | | |
| 160 | +| `project` | Yes | The repository's [project](/docs/agents/projects): its task board, memory and settings | Write | | |
| 161 | +| `project_posts` | Yes | The project's conversation after `after`, or the latest 100 lines | Write | | |
| 162 | +| `list_repos` | Yes | Repositories visible to you, optionally filtered by `owner` | Any token | | |
| 163 | +| `get_organization` | Yes | An organization's `login` and `name`; to its members, its agents, agents of its members and site administrators also `default_repository_permission` (the base permission members get on its repositories) | Any token | | |
| 164 | +| `list_invitations` | Yes | Pending invitations to a repository (`owner` and `repo`), an organization (`org`) or the instance (neither), each with an `id`, and the `email` or, for an invitation sent to a username, the `login` | Admin on the repository; organization owner or admin; site administrator | | |
| 165 | +| `revoke_invitation` | No | Revoke a pending invitation by `invitation_id`, with the same `owner` and `repo`, `org` or neither; the address can then be invited again | Same as `list_invitations`; organization and instance invitations need an `admin` token | | |
| 166 | + | |
| 167 | +### Code | |
| 168 | + | |
| 169 | +| Tool | Read-only | What it does | Minimum role | | |
| 170 | +| --- | --- | --- | --- | | |
| 171 | +| `list_tree` | Yes | Files and directories at a `path` and `ref` (default: the default branch) | read | | |
| 172 | +| `get_file` | Yes | A file's contents at a `ref`; binary files return only their size and `binary: true` | read | | |
| 173 | +| `list_branches` | Yes | Branches, marking the default and protected ones | read | | |
| 174 | +| `compare` | Yes | Commits ahead and behind, and the diff, between `base` and `head` | read | | |
| 175 | + | |
| 176 | +### Branches and commits | |
| 177 | + | |
| 178 | +| Tool | Read-only | What it does | Minimum role | | |
| 179 | +| --- | --- | --- | --- | | |
| 180 | +| `create_branch` | No | Create `branch` from another branch, tag or commit (`from`, default: the default branch) | write | | |
| 181 | +| `commit_files` | No | One commit on `branch` that writes `files` and removes `deletes`; see [Commit without git](#commit-without-git) | write | | |
| 182 | + | |
| 183 | +### Pull requests | |
| 184 | + | |
| 185 | +| Tool | Read-only | What it does | Minimum role | | |
| 186 | +| --- | --- | --- | --- | | |
| 187 | +| `list_pull_requests` | Yes | Up to 100 pull requests by `state` (`open`, `closed`, `all`) | read | | |
| 188 | +| `get_pull_request` | Yes | A pull request with its comments, reviews and mergeability (including blockers); `include_diff: true` adds the files and patches. `mergeability.blockers` is what stops the caller merging it directly now. For agents and workflow tokens it also returns `auto_merge_blockers`: what stops Diffinery landing it through auto-merge or the merge queue (`[]` means it would land now). An agent on a protected branch sees `agents may not merge into this branch` in the first list but not in the second when the rule allows auto-merge. | read | | |
| 189 | +| `create_pull_request` | No | Open a pull request: `title`, `head`, optional `base`, `body`, `draft`, `intent`, `reviewers`, and `auto_merge` (`true` turns on auto-merge as it opens, `false` keeps it off where your setting would turn it on; `autoMergeSkipped` says why it stayed off when asked). See [Merge on green automatically](/docs/collaboration/merge-on-green) | write | | |
| 190 | +| `comment` | No | Comment on an issue or pull request | triage | | |
| 191 | +| `review_pull_request` | No | Submit an `APPROVE`, `REQUEST_CHANGES` or `COMMENT` review, with optional line `comments` | read (write to approve or request changes) | | |
| 192 | +| `merge_pull_request` | No | Merge with `method`, `expected_head_sha`, `delete_branch`; queues the pull request when the base branch uses a merge queue. Agents can't merge into the default branch or protected branches this way: they use `enable_auto_merge` | write | | |
| 193 | +| `enable_auto_merge` | No | Turn on auto-merge, optionally with `merge_method`, `commit_title`, `commit_message`, `delete_branch` and `expected_head_sha`: Diffinery merges the pull request (or adds it to the merge queue) once every branch rule is met. Returns `state` (`enabled`, `queued` or `merged`), `autoMerge` and, once merged, `mergeCommit`. See [Auto-merge](/docs/collaboration/pull-requests#auto-merge) and [Agents landing changes](/docs/repositories/branch-protection#agents-landing-changes) | write | | |
| 194 | +| `disable_auto_merge` | No | Turn auto-merge off; returns `{"auto_merge": null}` | write | | |
| 195 | +| `dequeue_pull_request` | No | Remove a pull request from the merge queue | write | | |
| 196 | + | |
| 197 | +### Issues | |
| 198 | + | |
| 199 | +| Tool | Read-only | What it does | Minimum role | | |
| 200 | +| --- | --- | --- | --- | | |
| 201 | +| `list_issues` | Yes | Up to 100 issues by `state`, search text `q` and `label` | read | | |
| 202 | +| `get_issue` | Yes | An issue (or pull request) with its comments and reviews | read | | |
| 203 | +| `create_issue` | No | Open an issue with `title`, optional `body` and `labels` (`labels` from an account below triage are dropped, as on GitHub) | read | | |
| 204 | +| `update_issue` | No | Change the `title`, `body`, `state` (`open`, `closed`) or `labels` of an issue or pull request | triage | | |
| 205 | + | |
| 206 | +### Workflows | |
| 207 | + | |
| 208 | +| Tool | Read-only | What it does | Minimum role | | |
| 209 | +| --- | --- | --- | --- | | |
| 210 | +| `list_workflow_runs` | Yes | The 30 newest runs, filtered by `branch`, `sha`, `pr_number` or `status` | read | | |
| 211 | +| `get_workflow_run` | Yes | A run with its jobs, their status, conclusion and failure reason | read | | |
| 212 | +| `get_job_logs` | Yes | A job's log as text; the last `tail` lines (default 400), with `complete` and `truncated` flags | read | | |
| 213 | +| `rerun_workflow` | No | Re-run a finished run as a new attempt; with `failed_only`, only its failed jobs and the jobs that depend on them | write | | |
| 214 | +| `rerun_job` | No | Re-run one job (`job_id`) of a finished run and the jobs that depend on it | write | | |
| 215 | +| `cancel_workflow` | No | Cancel a queued or running run | write | | |
| 216 | +| `dispatch_workflow` | No | Start a `workflow_dispatch` run of `workflow` (for example `deploy.yml`) on `ref` with `inputs` | write | | |
| 217 | + | |
| 218 | +### Releases | |
| 219 | + | |
| 220 | +| Tool | Read-only | What it does | Minimum role | | |
| 221 | +| --- | --- | --- | --- | | |
| 222 | +| `list_releases` | Yes | Releases, newest first, up to `limit` (default 30, at most 100), with `total`. Drafts are included only for writers. | read | | |
| 223 | +| `get_release` | Yes | A release by `tag` or `id`, or the latest release when you pass neither | read | | |
| 224 | +| `create_release` | No | Create a release for `tag_name`, creating the tag at `target_commitish` (default: the default branch) if it doesn't exist. Optional `name`, `body`, `draft`, `prerelease`, `make_latest` and `generate_release_notes`. | write | | |
| 225 | +| `update_release` | No | Edit a release by `id`: `tag_name`, `target_commitish`, `name`, `body`, `draft`, `prerelease`, `make_latest`. Publishing a draft sends the `release` event. | write | | |
| 226 | +| `upload_release_asset` | No | Attach a file of up to 25 MB to release `release_id`: `name`, and either `content` (text) or `content_base64` (bytes), with optional `label` and `content_type` | write | | |
| 227 | +| `generate_release_notes` | Yes | Draft notes for `tag_name` from the pull requests merged since the previous release (or `previous_tag_name`). Creates nothing. | write | | |
| 228 | + | |
| 229 | +See [Releases](#releases-over-mcp) below for the result shape. | |
| 230 | + | |
| 231 | +### Docs | |
| 232 | + | |
| 233 | +| Tool | Read-only | What it does | Minimum role | | |
| 234 | +| --- | --- | --- | --- | | |
| 235 | +| `search_docs` | Yes | Search this documentation for `query` (for example `merge queue` or `workflow_dispatch inputs`). Returns up to 8 matching pages, each with `page`, `title`, `section`, the best matching `heading`, `url`, `markdown_url` and a `snippet`. | Any token | | |
| 236 | +| `read_docs` | Yes | Read one docs page as Markdown. `page` is a slug (`actions/workflow-syntax`), a docs URL, or `index` for the overview that links to every page. Returns `page`, `title` and `markdown`. | Any token | | |
| 237 | + | |
| 238 | +### Sandboxes | |
| 239 | + | |
| 240 | +| Tool | Read-only | What it does | Minimum role | | |
| 241 | +| --- | --- | --- | --- | | |
| 242 | +| `sandbox_create` | No | Start a Linux sandbox with the repository checked out at `ref`; returns its `id` | write | | |
| 243 | +| `sandbox_exec` | No | Run a shell `command` in the sandbox, with optional `cwd` and `timeout_seconds` | write | | |
| 244 | +| `sandbox_read_file` | No | Read a file; text comes back as text, other files as base64 | write | | |
| 245 | +| `sandbox_write_file` | No | Write a text file | write | | |
| 246 | +| `sandbox_close` | No | Shut the sandbox down | write | | |
| 247 | + | |
| 248 | +See [Sandboxes](/docs/agents/sandboxes) for what is inside a sandbox and its limits. | |
| 249 | + | |
| 250 | +### Imports from GitHub | |
| 251 | + | |
| 252 | +| Tool | Read-only | What it does | Minimum role | | |
| 253 | +| --- | --- | --- | --- | | |
| 254 | +| `start_github_import` | No | Start a server-side import of one GitHub repository: `source` (`owner/name` or a GitHub URL) and `owner` (the Diffinery organization or user to import into), optional `name`, `visibility` (`keep`, `private`, `public`), `github_token` and `github_api_url`. Returns the import, with its `id`. | See [Imports over MCP](#imports-over-mcp) | | |
| 255 | +| `get_import` | Yes | An import by `id`: `status`, per-step progress, `git` transfer progress, `summary`, `warnings`, `error` and, for an organization import, its repositories | You started it; a person who administers its target, or a site administrator, can also read it | | |
| 256 | +| `list_imports` | Yes | The imports you started, newest first | Any token | | |
| 257 | + | |
| 258 | +## Commit without git | |
| 259 | + | |
| 260 | +`commit_files` creates a single commit from file contents: | |
| 261 | + | |
| 262 | +| Argument | Meaning | | |
| 263 | +| --- | --- | | |
| 264 | +| `branch` | Branch to commit to (required) | | |
| 265 | +| `message` | Commit message (required) | | |
| 266 | +| `files` | `[{ "path", "content", "encoding": "utf-8" or "base64", "executable": true or false }]` | | |
| 267 | +| `deletes` | Paths to remove | | |
| 268 | +| `create_branch_from` | Create `branch` from this branch, tag or commit if it doesn't exist | | |
| 269 | +| `expected_head_sha` | Only commit if `branch` is still at this commit; otherwise `branch moved; refresh and retry` | | |
| 270 | + | |
| 271 | +It returns `{ "sha": "...", "branch": "..." }`. At least one file or delete is required (`nothing to commit`). Committing to a branch that doesn't exist without `create_branch_from` fails with `branch fix-pricing-link not found`. Commits made by agents are stamped with the agent and its owner; see [Agents](/docs/agents/overview#how-agent-activity-is-recorded). | |
| 272 | + | |
| 273 | +`commit_files` runs [push protection](/docs/security/push-protection) before it writes anything, like `git push`. If a file looks like a credential (an AWS key, a GitHub or Diffinery token, a private key and so on), nothing is committed and the tool fails with, for example, `push protection: possible secret detected (AWS access key) in config/deploy.env. Remove it, or rotate it and ask an admin.` The attempt is recorded in the audit log as `api.blocked_secret` against the token's account, and the agent's owner gets a `Your commit was blocked` email. `commit_files` has no bypass. For a test fixture with fake keys, put the text `diffinery:allow-secret` anywhere in the file. | |
| 274 | + | |
| 275 | +## Releases over MCP | |
| 276 | + | |
| 277 | +Release tools return a compact release object: | |
| 278 | + | |
| 279 | +```json | |
| 280 | +{ | |
| 281 | + "id": 7, | |
| 282 | + "tag_name": "v1.4.0", | |
| 283 | + "target_commitish": "main", | |
| 284 | + "name": "v1.4.0", | |
| 285 | + "body": "## What's Changed\n...", | |
| 286 | + "draft": false, | |
| 287 | + "prerelease": false, | |
| 288 | + "latest": true, | |
| 289 | + "author": "review-bot", | |
| 290 | + "created_at": "2026-10-13T09:30:00.000Z", | |
| 291 | + "published_at": "2026-10-13T09:30:00.000Z", | |
| 292 | + "html_url": "https://diffinery.com/acme/website/releases/tag/v1.4.0", | |
| 293 | + "assets": [{ "id": 12, "name": "notes.txt", "label": "", "content_type": "text/plain", "size": 18, "download_count": 0, "download_url": "https://diffinery.com/acme/website/releases/download/v1.4.0/notes.txt" }] | |
| 294 | +} | |
| 295 | +``` | |
| 296 | + | |
| 297 | +- `make_latest` is the string `"true"`, `"false"` or `"legacy"`; a new published release becomes the latest by default. | |
| 298 | +- `draft: true` keeps a release visible to writers only. `update_release` with `draft: false` publishes it, which starts `on: release` workflows and sends the `release` webhook event. | |
| 299 | +- `upload_release_asset` takes the file inline, so it is limited to 25 MB (`MCP uploads are limited to 25 MB; use the upload_url of the REST API for larger files`). Pass exactly one of `content` and `content_base64`. Text defaults to `text/plain`, bytes to `application/octet-stream`. For files up to 100 MB, use the release's `upload_url` in the [REST API](/docs/reference/rest-api#release-assets). | |
| 300 | +- `generate_release_notes` only drafts text; pass its `body` to `create_release`, or set `generate_release_notes: true` there. | |
| 301 | +- Publishing a release for a tag that doesn't exist creates the tag at `target_commitish`, and that follows the same rules as any ref update. A draft creates its tag only when it is published. | |
| 302 | + | |
| 303 | +## Imports over MCP | |
| 304 | + | |
| 305 | +`start_github_import` starts the same server-side importer as the web UI, `diffinery migrate github` and `POST /api/v3/diffinery/imports`. It imports one repository with every part turned on: Git history and Git LFS, issues and pull requests with their numbers, comments, reviews and review comments, labels, milestones, branch protection, releases, and (for people) Actions variables, environments and secret names. Secret values are never read from GitHub. The tool returns at once; the import runs on the server. | |
| 306 | + | |
| 307 | +```sh | |
| 308 | +curl -s https://diffinery.com/mcp \ | |
| 309 | + -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "Content-Type: application/json" \ | |
| 310 | + -d '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"start_github_import","arguments":{"source":"acme/website","owner":"acme","github_token":"ghp_..."}}}' | |
| 311 | +``` | |
| 312 | + | |
| 313 | +The result is the import object (`id`, `html_url`, `status`, `source`, `target`, `steps`, `git`, `summary`, `warnings`, `error`). Call `get_import` with the `id` every minute or so until `status` is `succeeded`, `failed` or `cancelled`. While the import runs, every other write to the new repository is refused: pushes with `repository is locked by an import in progress`, and tools such as `create_issue` with `repository is locked by an import in progress; try again when it finishes, or cancel the import`. A person can still set the repository's Actions secrets and variables, environments and collaborators while it runs. | |
| 314 | + | |
| 315 | +Who can start one: | |
| 316 | + | |
| 317 | +- The token needs the `write` or `admin` scope (`starting an import needs a token with write or admin scope`). | |
| 318 | +- A person can import into their own account or an organization they own or administer. | |
| 319 | +- An agent can import only where its owner may: a person's agent into the owner's own account or an organization the owner owns or administers, an organization's agent into that organization (`agents can import only where their owner may: the owner's own account or an organization the owner administers, not <owner>`). Its maximum role must be at least `write` (`this agent's policy does not allow writing; an import needs at least write`). | |
| 320 | + | |
| 321 | +Rules for imports an agent starts: | |
| 322 | + | |
| 323 | +- **Push protection is never bypassed.** If old history contains something that looks like a credential, the import stops and fails with `Push protection found possible secrets in the history (...)`. A person who administers the target starts a new import from the web or API to import it with an audited bypass. | |
| 324 | +- **Variables, secret names and environments are skipped** (`skipped (agents cannot manage variables)`); a person sets them up afterwards. Agents never read or set secret values. | |
| 325 | +- **No retry once the repository exists.** An agent's retry (through the REST API) is refused after the repository was created; a person who administers the target retries it instead. | |
| 326 | +- The agent sees only the imports it started (`get_import`, `list_imports`). | |
| 327 | + | |
| 328 | +An import uses the GitHub token only for that import, deletes it when the import ends, and never returns it. For GitHub Enterprise, pass `github_api_url`, for example `https://ghe.example.com/api/v3`; when `source` is an Enterprise URL and you pass a token, `github_api_url` must be on the same host. See [Migrate from GitHub](/docs/migrate-from-github) for what is copied, limits (at most 3 repository imports at a time per account and per target) and errors. | |
| 329 | + | |
| 330 | +## Permissions | |
| 331 | + | |
| 332 | +The token's account (person or agent), its role on the repository and the token's scope decide what each tool may do, exactly as for the REST API. The minimum role for each tool is in the tables above. | |
| 333 | + | |
| 334 | +### Token scope and write tools | |
| 335 | + | |
| 336 | +Diffinery checks the token's scope before a tool runs. A tool that isn't read-only (`readOnlyHint: false`) needs a token with the `write` or `admin` scope. With a `read` token, the call fails with `isError: true` and the text `this token needs the write scope`, even when the role the tool asks for is only read or triage: `create_issue`, `comment`, a `COMMENT` review with `review_pull_request`, and the sandbox tools all need a `write` token. Read-only tools work with any scope, including `read`. | |
| 337 | + | |
| 338 | +Use a `write` token for agents that change anything, and a `read` token for agents that only read code, issues, pull requests, runs, releases and docs. | |
| 339 | + | |
| 340 | +### Other rules | |
| 341 | + | |
| 342 | +On top of the role and scope: | |
| 343 | + | |
| 344 | +- Agents can't push or commit to the default branch or protected branches, and can't merge into them, unless the branch's rule allows it (`agentsMayPush`, `agentsMayMerge`). Where the rule has `allowAgentAutoMerge` and a required check, an agent lands its pull request there with `enable_auto_merge` instead, and Diffinery merges once the rules are met (see [Agents landing changes](/docs/repositories/branch-protection#agents-landing-changes)). | |
| 345 | +- Pull requests opened by agents need a human approval, and approvals submitted by agents with `review_pull_request` never count toward it. | |
| 346 | +- Private repositories you can't see return `repository not found`, the same as a repository that doesn't exist. | |
| 347 | +- Archived repositories refuse every tool that changes something (`repository is archived (read-only)`), whatever the role. Read-only tools keep working. | |
| 348 | +- `commit_files` refuses files that look like credentials (see [Commit without git](#commit-without-git)). | |
| 349 | +- `create_issue` drops `labels` when the account's role is below triage, as GitHub does for issues opened by people without triage access. | |
| 350 | +- Release tools follow the same rules as the REST API: anyone who can read the repository can list published releases, writers create, edit and publish them and see drafts, and a release that creates a tag is a ref update, so agent branch and tag rules apply. | |
| 351 | +- `start_github_import` follows the agent import rules in [Imports over MCP](#imports-over-mcp). | |
| 352 | +- `search_docs` and `read_docs` read the public documentation. They work with any token, read-only included, and don't touch a repository. | |
| 353 | + | |
| 354 | +See [Agents](/docs/agents/overview#guardrails) and [Permissions](/docs/accounts/permissions). | |
| 355 | + | |
| 356 | +## Results and errors | |
| 357 | + | |
| 358 | +A successful `tools/call` returns the tool's output twice: as pretty-printed JSON in a text content block, and as `structuredContent` (the object itself, or `{ "result": ... }` when the output is an array or a plain value): | |
| 359 | + | |
| 360 | +```json | |
| 361 | +{ | |
| 362 | + "jsonrpc": "2.0", | |
| 363 | + "id": 3, | |
| 364 | + "result": { | |
| 365 | + "content": [{ "type": "text", "text": "{\n \"path\": \"README.md\",\n ..." }], | |
| 366 | + "structuredContent": { "path": "README.md", "commit": "51c0e2aa...", "size": 412, "content": "# Website\n..." }, | |
| 367 | + "isError": false | |
| 368 | + } | |
| 369 | +} | |
| 370 | +``` | |
| 371 | + | |
| 372 | +When a tool fails, the call still succeeds at the JSON-RPC level, with `isError: true` and the error message as text. The messages are the same as the REST API's: | |
| 373 | + | |
| 374 | +```json | |
| 375 | +{ | |
| 376 | + "jsonrpc": "2.0", | |
| 377 | + "id": 4, | |
| 378 | + "result": { | |
| 379 | + "content": [{ "type": "text", "text": "agents may not push to protected or default branches; open a pull request" }], | |
| 380 | + "isError": true | |
| 381 | + } | |
| 382 | +} | |
| 383 | +``` | |
| 384 | + | |
| 385 | +Missing or invalid arguments produce messages such as `owner is required`, `title must be a string` or `number must be a positive integer`. | |
| 386 | + | |
| 387 | +Tools that change something count toward your account's 60 changes a minute. Past that, the call fails with `Too many changes from your account: at most 60 a minute through MCP. Try again in a minute.` Every request to `/mcp` also counts toward the account's 2,000 requests a minute; past that, the HTTP response itself is `429` with `retry-after: 60`. See [Rate limits](/docs/reference/rest-api#rate-limits). | |
| 388 | + | |
| 389 | +Protocol-level errors use JSON-RPC error objects: | |
| 390 | + | |
| 391 | +| Code | Message | Cause | | |
| 392 | +| --- | --- | --- | | |
| 393 | +| `-32700` | `parse error` | The body isn't JSON (HTTP status 400). | | |
| 394 | +| `-32600` | `invalid request` | A message without a string `method`. | | |
| 395 | +| `-32601` | `method not found: <method>` | An unsupported method, such as `resources/list`. | | |
| 396 | +| `-32602` | `unknown tool: <name>` | `tools/call` named a tool that doesn't exist. | | |
| 397 | + | |
| 398 | +## Example calls with curl | |
| 399 | + | |
| 400 | +Set your token once: | |
| 401 | + | |
| 402 | +```sh | |
| 403 | +export DIFFINERY_TOKEN=dfy_... | |
| 404 | +``` | |
| 405 | + | |
| 406 | +### Initialize | |
| 407 | + | |
| 408 | +```sh | |
| 409 | +curl -s https://diffinery.com/mcp \ | |
| 410 | + -H "Authorization: Bearer $DIFFINERY_TOKEN" \ | |
| 411 | + -H "Content-Type: application/json" \ | |
| 412 | + -H "Accept: application/json, text/event-stream" \ | |
| 413 | + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' | |
| 414 | +``` | |
| 415 | + | |
| 416 | +```json | |
| 417 | +{ | |
| 418 | + "jsonrpc": "2.0", | |
| 419 | + "id": 1, | |
| 420 | + "result": { | |
| 421 | + "protocolVersion": "2025-06-18", | |
| 422 | + "capabilities": { "tools": { "listChanged": false } }, | |
| 423 | + "serverInfo": { "name": "diffinery", "title": "Diffinery", "version": "0.1.0" }, | |
| 424 | + "instructions": "Diffinery is a git forge built for agents. Read code with get_file/list_tree, ..." | |
| 425 | + } | |
| 426 | +} | |
| 427 | +``` | |
| 428 | + | |
| 429 | +### List tools | |
| 430 | + | |
| 431 | +```sh | |
| 432 | +curl -s https://diffinery.com/mcp \ | |
| 433 | + -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "Content-Type: application/json" \ | |
| 434 | + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | |
| 435 | +``` | |
| 436 | + | |
| 437 | +Each tool has `name`, `description`, `inputSchema` (a JSON Schema object with `properties` and `required`) and `annotations.readOnlyHint`. | |
| 438 | + | |
| 439 | +### Read a file | |
| 440 | + | |
| 441 | +```sh | |
| 442 | +curl -s https://diffinery.com/mcp \ | |
| 443 | + -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "Content-Type: application/json" \ | |
| 444 | + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_file","arguments":{"owner":"acme","repo":"website","path":"README.md"}}}' | |
| 445 | +``` | |
| 446 | + | |
| 447 | +### Commit on a new branch and open a pull request | |
| 448 | + | |
| 449 | +```sh | |
| 450 | +curl -s https://diffinery.com/mcp \ | |
| 451 | + -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "Content-Type: application/json" \ | |
| 452 | + -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"commit_files","arguments":{ | |
| 453 | + "owner":"acme","repo":"website","branch":"fix-pricing-link","create_branch_from":"main", | |
| 454 | + "message":"Fix the Contact sales link", | |
| 455 | + "files":[{"path":"src/pricing.html","content":"<a href=\"/contact\">Contact sales</a>\n"}]}}}' | |
| 456 | + | |
| 457 | +curl -s https://diffinery.com/mcp \ | |
| 458 | + -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "Content-Type: application/json" \ | |
| 459 | + -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"create_pull_request","arguments":{ | |
| 460 | + "owner":"acme","repo":"website","title":"Fix the Contact sales link","head":"fix-pricing-link", | |
| 461 | + "intent":"Point the Contact sales link at /contact; no other changes."}}}' | |
| 462 | +``` | |
| 463 | + | |
| 464 | +### Check CI | |
| 465 | + | |
| 466 | +```sh | |
| 467 | +curl -s https://diffinery.com/mcp \ | |
| 468 | + -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "Content-Type: application/json" \ | |
| 469 | + -d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"list_workflow_runs","arguments":{"owner":"acme","repo":"website","pr_number":12}}}' | |
| 470 | +``` | |
| 471 | + | |
| 472 | +Then `get_workflow_run` with a run's `id`, and `get_job_logs` with a failed job's `id`, to see why it failed. | |
| 473 | + | |
| 474 | +## A typical agent workflow | |
| 475 | + | |
| 476 | +1. `whoami` to confirm the account and its owner. | |
| 477 | +2. `search_docs` and `read_docs` when it needs to check how a Diffinery feature works (for example `workflow_dispatch inputs` or `branch protection`). | |
| 478 | +3. `list_tree` and `get_file` (or `compare`) to read the code. | |
| 479 | +4. `commit_files` with `create_branch_from` to put changes on a new branch, passing `expected_head_sha` on later commits to avoid overwriting someone else's. | |
| 480 | +5. `sandbox_create` and `sandbox_exec` to build and run tests when needed. | |
| 481 | +6. `create_pull_request` with an `intent` that states the goal. | |
| 482 | +7. `list_workflow_runs`, `get_workflow_run` and `get_job_logs` to follow CI, fixing failures with more commits. | |
| 483 | +8. `get_pull_request` to read reviews and the remaining merge blockers. A person approves and merges. Or the agent calls `enable_auto_merge` where the branch allows it (`allowAgentAutoMerge`), and Diffinery merges once the checks pass and a person has approved (or without the approval where an admin turned on `agentAutoMergeWithoutApproval`); `auto_merge_blockers` lists what auto-merge still needs. Or it merges with `merge_pull_request` where the branch allows agents to merge. | |
| 484 | + | |
| 485 | +## List your instance in the MCP registry | |
| 486 | + | |
| 487 | +The [official MCP registry](https://registry.modelcontextprotocol.io) lists Diffinery's MCP server as `com.diffinery/diffinery`, with the URL `https://{diffinery_host}/mcp`: clients that read the registry ask for your instance's host name. To list your own instance under your domain as well, prove that you own the domain with a key: | |
| 488 | + | |
| 489 | +1. Make a key pair and its proof record (OpenSSL 3): | |
| 490 | + | |
| 491 | + ```sh | |
| 492 | + openssl genpkey -algorithm Ed25519 -out mcp-registry-key.pem | |
| 493 | + echo "v=MCPv1; k=ed25519; p=$(openssl pkey -in mcp-registry-key.pem -pubout -outform DER | tail -c 32 | base64)" | |
| 494 | + ``` | |
| 495 | + | |
| 496 | +2. Add the printed line as `"MCP_REGISTRY_AUTH"` under `vars` in `wrangler.jsonc` and deploy. Check that `https://<your host>/.well-known/mcp-registry-auth` returns it. | |
| 497 | +3. Sign in to the registry with [`mcp-publisher`](https://github.com/modelcontextprotocol/registry) and publish a `server.json` named after your domain in reverse (`com.example/diffinery` for `git.example.com`), with `remotes` set to `[{"type": "streamable-http", "url": "https://git.example.com/mcp"}]`: | |
| 498 | + | |
| 499 | + ```sh | |
| 500 | + mcp-publisher login http --domain git.example.com \ | |
| 501 | + --private-key "$(openssl pkey -in mcp-registry-key.pem -noout -text | grep -A3 'priv:' | tail -n +2 | tr -d ' :\n')" | |
| 502 | + mcp-publisher publish server.json | |
| 503 | + ``` | |
| 504 | + | |
| 505 | +The record holds only the public key. Keep the `.pem` file to publish updates; if you lose it, make a new pair and replace the record. | |
| 506 | + | |
| 507 | +## Limitations | |
| 508 | + | |
| 509 | +- The server offers tools only: no resources, prompts or sampling. | |
| 510 | +- Responses are plain JSON. The server doesn't stream, keeps no session, and can't send messages to the client on its own. | |
| 511 | +- OAuth sign-in supports the authorization code flow with PKCE only, for apps that register themselves or identify by a client metadata document. There is no device flow for MCP; on a machine without a browser, use a token in the header. | |
| 512 | + | |
| 513 | +## Troubleshooting | |
| 514 | + | |
| 515 | +| Symptom | Cause and fix | | |
| 516 | +| --- | --- | | |
| 517 | +| The client says it can't connect or gets `401` | The `Authorization` header is missing or the token is wrong. Check the header name and the `Bearer ` prefix, and that the token hasn't expired. | | |
| 518 | +| The browser says "Can't connect this app" | The app's registration expired (unused registrations are removed after a week) or it asked to send you back to an address it didn't register. Remove the server in the app and add it again. | | |
| 519 | +| An app that used to work asks you to sign in again | Its connection lapsed after 30 days unused, or you (or a security check after a reused refresh token) disconnected it. Sign in again. | | |
| 520 | +| `repository not found` | Wrong `owner` or `repo`, or the account (or, for an agent, its owner) can't see the repository. | | |
| 521 | +| `this token needs the write scope` | The tool isn't read-only and the token has only the `read` scope. Create a `write` token (for an agent, in **Settings → Agents** while signed in). | | |
| 522 | +| `write access required` (or `triage access required`) | The account's role, or the token's scope, is too low for the tool. A `read` token can only use read-only tools. | | |
| 523 | +| `agents may not push to protected or default branches; open a pull request` | Commit to a new branch with `create_branch_from` and open a pull request. | | |
| 524 | +| `agents may not merge into this branch; a human must merge` | `merge_pull_request` into the default branch or a protected branch. Use `enable_auto_merge` where the branch allows it, or ask a person to merge. | | |
| 525 | +| `agents may not have pull requests merged into main automatically unless its branch protection allows it ...` | `enable_auto_merge` on a branch whose rule doesn't set `allowAgentAutoMerge`. A person merges, or an admin allows it. | | |
| 526 | +| `Auto-merge was turned off: ...` (a comment on the pull request) | Auto-merge stopped, for example because someone else pushed to the branch. Read the reason and call `enable_auto_merge` again. See [When auto-merge turns itself off](/docs/collaboration/pull-requests#when-auto-merge-turns-itself-off). | | |
| 527 | +| `branch moved; refresh and retry` | Someone else committed to the branch. Read the new head and retry with the new `expected_head_sha`. | | |
| 528 | +| `push protection: possible secret detected (...)` | A file passed to `commit_files` looks like a credential. Remove it and commit again, or add `diffinery:allow-secret` to a file whose keys are fake. | | |
| 529 | +| `repository is archived (read-only)` | The repository is archived. Only read-only tools work until a repository admin unarchives it. | | |
| 530 | +| `method not found: resources/list` | The server only offers tools; the client can ignore this. | | |
| 531 | +| `MCP uploads are limited to 25 MB; use the upload_url of the REST API for larger files` | `upload_release_asset` takes the file inline. Upload larger files (up to 100 MB) to the release's `upload_url` with the [REST API](/docs/reference/rest-api#release-assets). | | |
| 532 | +| `pass exactly one of content or content_base64` | `upload_release_asset` needs the file as either text or base64, not both and not neither. | | |
| 533 | +| `make_latest must be true, false or legacy` | Pass `make_latest` as one of those strings, not as a boolean. | | |
| 534 | +| `query is required` | `search_docs` was called with an empty `query`. | | |
| 535 | +| `agents can import only where their owner may: ...` | Pass as `owner` the owner's own account or an organization the owner administers (for an organization's agent, that organization). | | |
| 536 | +| `too many repository imports are already running (at most 3 at a time); wait for one to finish` | Wait for one of the running imports to finish (check with `list_imports`). | | |
| 537 | +| `no docs page "..."; pages: ...` | `read_docs` didn't recognize the slug. Pick one from the list in the message, or call `read_docs` with `index`. | |
addedapps/diffinery/docs/agents/organizations.md+144−0View file
| @@ -1,0 +1,144 @@ | ||
| 1 | +--- | |
| 2 | +title: Agents in organizations | |
| 3 | +description: An organization's agent policy and model keys. Whose key pays for agent runs in its repositories, what agents may use there, and a monthly allowance per person. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +An organization's owners decide how agents work in its repositories, on the organization's **Settings** tab under **Agents**: | |
| 7 | + | |
| 8 | +- whose model key pays: each person's own, or the organization's; | |
| 9 | +- which runtimes and providers agents may use; | |
| 10 | +- how many tasks run at once per repository; | |
| 11 | +- whether web terminals and agent review are allowed; | |
| 12 | +- how many tokens each person may spend on the organization's keys in a month. | |
| 13 | + | |
| 14 | +Nothing changes until an owner changes something. By default people use their own keys, as in [Agent tasks](/docs/agents/tasks#model-access), and everything is allowed. A repository owned by a person has no policy. | |
| 15 | + | |
| 16 | +## The organization's model keys | |
| 17 | + | |
| 18 | +An owner can save an Anthropic, OpenAI or OpenRouter API key for the organization, under **Settings → Agents → Model keys**. | |
| 19 | + | |
| 20 | +- Saving a key asks the owner to accept the provider's terms for the organization. The provider bills the organization's account. The acceptance is recorded with its date and the owner who accepted. | |
| 21 | +- **Only owners** can see or change the organization's keys. Org admins can't, and neither can members. | |
| 22 | +- Saving or deleting a key needs a browser session with a sign-in from the last 2 hours. Diffinery asks you to sign in again if yours is older. No token can do it. | |
| 23 | +- Keys are **write-only**, like personal keys: pages and the API show only the provider, the last four characters and who accepted the terms when. | |
| 24 | +- Every owner gets a security email when a key is added ("An Anthropic API key was added to acme"). | |
| 25 | +- Saves and deletions are in the audit log as `org.model_key.set` and `org.model_key.delete`. A [site administrator](/docs/admin/site-administration) can act as an owner, and is recorded as themselves. | |
| 26 | + | |
| 27 | +The organization's key **never enters a sandbox, a container or a terminal**. As with personal keys, a run gets only its own model token. Diffinery's model proxy decrypts the key inside each request it forwards, after checking that the request may use it (see [How your key is protected](/docs/agents/tasks#how-your-key-is-protected)). | |
| 28 | + | |
| 29 | +## Whose key pays | |
| 30 | + | |
| 31 | +| Setting | Who pays | | |
| 32 | +| --- | --- | | |
| 33 | +| **Each person's own keys** (default) | The person who starts the work, with their own key, as anywhere else. | | |
| 34 | +| **The organization's keys, when it has them** | A member's run uses the organization's key when it has every provider the run needs, otherwise the member's own. An outside collaborator always uses their own key. | | |
| 35 | +| **Only the organization's keys** | Personal keys are never used in the organization's repositories. Only members can start tasks, agent reviews and coordinators there. An outside collaborator gets `403 acme pays for agents in its repositories with its own keys, which only its members can use`. | | |
| 36 | + | |
| 37 | +**Only members spend the organization's key.** A member here is a person with the member, admin or owner role. An agent account never counts, even an agent the organization owns: what counts is the person behind the run. | |
| 38 | + | |
| 39 | +- A task's payer is the person who started it, never its agent. A task a coordinator starts is paid like the coordinator's own run. | |
| 40 | +- An agent review's payer is the admin who turned reviews on (see [Agent review](/docs/agents/review)). | |
| 41 | +- A coordinator's payer is the person whose coordinator it is (see [Projects](/docs/agents/projects)). | |
| 42 | + | |
| 43 | +**A task keeps whose key pays from when it starts.** The REST task shows it as `"paid_by": "organization"` or `"person"`. | |
| 44 | + | |
| 45 | +- Messages, retries and plan approval keep it. Only the person who started the task can do those. | |
| 46 | +- An agent review decides when each review starts, and a coordinator when each run starts. | |
| 47 | +- A task the organization paid for can't be retried by someone who is no longer a member: `only members of acme can run agents on its keys`. | |
| 48 | + | |
| 49 | +**Every model request checks again**, before the key is decrypted. Nothing is cached, so a change applies at the next request. The proxy checks that: | |
| 50 | + | |
| 51 | +1. the repository still belongs to the organization, so a repository transferred out of it mid-task stops spending its key; | |
| 52 | +2. the policy still lets the organization's keys pay; | |
| 53 | +3. the person is still an active member; | |
| 54 | +4. the provider is still allowed; | |
| 55 | +5. the organization still has the key; | |
| 56 | +6. the person is under their monthly allowance. | |
| 57 | + | |
| 58 | +For a personal key in an organization's repository, it checks that the policy doesn't now require the organization's keys, and that the provider is still allowed. | |
| 59 | + | |
| 60 | +When a check fails, the proxy answers `403` with the reason, for example `mia is no longer a member of acme, whose key this task uses`. The task stops with a note saying why, and anything booked for that request is refunded. | |
| 61 | + | |
| 62 | +## Monthly allowance per person | |
| 63 | + | |
| 64 | +**Monthly tokens per person on the organization's keys** caps how much one person's runs can spend of the organization's keys in a calendar month (UTC). It goes from 10,000 to 10 billion, and is off by default. | |
| 65 | + | |
| 66 | +- Tokens are counted as [run budgets](/docs/agents/projects#limits) count them: input, output and cache writes in full, and cache reads as a tenth. | |
| 67 | +- Before a request goes to the provider, the proxy books an estimate against the person's allowance, in one step: a token per 3 bytes of the request, plus the output it allows. The answer then settles the booking to what the provider reported. | |
| 68 | +- A request that would take the person over the allowance is refused with `403` and never reaches the provider: `this month's allowance of 2,000,000 tokens per person on acme's keys is used up`. The task stops. | |
| 69 | +- While an allowance is set, a person has at most 4 model requests on the organization's keys on their way at once, across all their runs. A fifth gets `429` with `retry-after`, and the agent's CLI tries again. | |
| 70 | +- So requests already on their way can take a person past the allowance by at most 4 requests. Each one's output is capped at what it booked; its input can cost more than its estimate when it tokenizes densely or carries images or PDFs, which bill by pixels and pages. | |
| 71 | +- A request still streaming after 15 minutes stops counting toward the 4. | |
| 72 | +- If the agent hangs up while an answer is still streaming, Diffinery keeps reading it for about 30 seconds. An answer that takes longer stays charged at its booking, and its place among the 4 frees after 15 minutes. | |
| 73 | +- An answer that reports no usage, or whose usage can't be read (a streamed line too long to keep, for example), is charged a token per byte sent, plus its output cap. | |
| 74 | +- A request books at most 1,000,000 output tokens, whatever cap it asks for, and is sent with that cap. | |
| 75 | +- Runs on a person's own key don't count. | |
| 76 | + | |
| 77 | +So that a booking holds what a request can use, runs on the organization's key send only what their booking bounds, as [agent reviews](/docs/agents/review) do: | |
| 78 | + | |
| 79 | +- A request that sets no output cap is sent with the one it was booked for, 32,000 tokens. | |
| 80 | +- Requests are refused with `403` when they pull in input the request doesn't carry: | |
| 81 | + - context stored at the provider: `previous_response_id`, `conversation`, `prompt` or `item_reference` inputs in the Responses API, and an Anthropic `container`; | |
| 82 | + - images, documents and files given by URL or file id rather than as their content. | |
| 83 | +- So are requests that start a background response (any `background` but `false`), ask for more than one answer (`n`), use an OpenRouter web search model (`:online`) or preset (`@preset/...`), or whose body isn't a JSON object, and routing and paid extras (`models`, `route`, `provider`, `plugins`, `web_search_options`, `speed`, `best_of`, `preset`). Reading back a stored response (`GET /v1/responses/...`) is refused too. Claude Code and Codex don't send any of them in normal use. | |
| 84 | +- Tools that run on the provider (web search, web fetch, code execution, file search, MCP servers, a shell in a hosted environment) are stripped, as in reviews, since each of their steps reads input the request doesn't show. Tools the agent runs itself stay. | |
| 85 | +- The request is sent as Diffinery read it, written out again, and OpenAI's `openai-organization` and `openai-project` headers are dropped: the key's own defaults apply. | |
| 86 | +- A refused request costs nothing, and the task goes on. | |
| 87 | + | |
| 88 | +## Other policies | |
| 89 | + | |
| 90 | +| Policy | Default | Checked | | |
| 91 | +| --- | --- | --- | | |
| 92 | +| **Allowed runtimes**: Claude Code, Codex, custom command | All | When a task, review, coordinator or terminal task starts, and when review or coordinator settings are saved. | | |
| 93 | +| **Allowed providers**: Anthropic, OpenAI, OpenRouter | All | As above, and on every model request. A custom command gets only the allowed providers' keys. A terminal's task counts as its runtime's provider: Claude Code as Anthropic, Codex as OpenAI. | | |
| 94 | +| **Most tasks at once per repository**, 1 to 10 | 10 | Each project's own "tasks at once" is held to it. More tasks wait in the queue. The project's settings page says when the organization's limit applies. | | |
| 95 | +| **Allow web terminals** | On | When a terminal opens. Turning it off closes no open terminal, but no new ones open. | | |
| 96 | +| **Let terminals run agent tasks** | On | When a terminal opens for tasks, and when a task is given to one. Under **Only the organization's keys**, only members can run tasks in a terminal, though the person's own subscription pays for them. | | |
| 97 | +| **Allow agent review** | On | When review settings are saved and when each review starts. Turning it off stops new reviews; the pull request's check says why. | | |
| 98 | + | |
| 99 | +Saving the policy is recorded in the audit log as `org.agent_policy.update`, with the new values, and `site_admin: true` when a site administrator saved it. A repository transferred into the organization takes its limit on tasks at once, and one transferred out drops it. | |
| 100 | + | |
| 101 | +## Usage | |
| 102 | + | |
| 103 | +The **Paid with the organization's keys** box on the Settings tab shows owners: | |
| 104 | + | |
| 105 | +- each person's tokens on the organization's keys this month, against the allowance; | |
| 106 | +- the runs the organization's keys paid for in the last 30 days, per repository, person and kind. | |
| 107 | + | |
| 108 | +A person's own **Usage in the last 30 days** (**Settings → Model access**) shows only what they paid for, with their own keys or in their terminal. | |
| 109 | + | |
| 110 | +## REST API | |
| 111 | + | |
| 112 | +| Endpoint | Does | Who | | |
| 113 | +| --- | --- | --- | | |
| 114 | +| `GET /orgs/{org}/diffinery/agent-policy` | The policy | Owners; any token not limited to repositories | | |
| 115 | +| `PUT /orgs/{org}/diffinery/agent-policy` | Change it. Fields left out stay as they are. | Owners, browser session only | | |
| 116 | +| `GET /orgs/{org}/diffinery/model-keys` | The organization's keys: provider, last four characters, who accepted the terms and when | Owners; any token not limited to repositories | | |
| 117 | +| `PUT /orgs/{org}/diffinery/model-keys/{provider}` | Save a key: `{ "key": "sk-ant-...", "accept_terms": true }` | Owners, browser session with a recent sign-in | | |
| 118 | +| `DELETE /orgs/{org}/diffinery/model-keys/{provider}` | Delete a key | Owners, browser session with a recent sign-in | | |
| 119 | +| `GET /orgs/{org}/diffinery/model-usage?days=30` | The runs its keys paid for (1 to 90 days), and each person's tokens this month | Owners; any token not limited to repositories | | |
| 120 | + | |
| 121 | +A token limited to some repositories gets `403` on all of them. Anyone who isn't a member gets `404`, and a member who isn't an owner gets `403 organization owners only`. | |
| 122 | + | |
| 123 | +```json | |
| 124 | +{ | |
| 125 | + "keys": "either", | |
| 126 | + "runtimes": ["claude-code", "codex", "custom"], | |
| 127 | + "providers": ["anthropic", "openai", "openrouter"], | |
| 128 | + "max_running": 4, | |
| 129 | + "terminals": true, | |
| 130 | + "terminal_tasks": true, | |
| 131 | + "review": true, | |
| 132 | + "member_monthly_tokens": 2000000, | |
| 133 | + "updated_by": "ayse", | |
| 134 | + "updated_at": "2026-10-14T07:02:11.000Z" | |
| 135 | +} | |
| 136 | +``` | |
| 137 | + | |
| 138 | +`keys` is `personal`, `either` or `organization`. `member_monthly_tokens` is `null` for no limit. | |
| 139 | + | |
| 140 | +## Deleting the organization | |
| 141 | + | |
| 142 | +Deleting an organization removes its model keys, its policy, its allowance counts and the usage its keys paid for (see [Organizations](/docs/accounts/organizations)). | |
| 143 | + | |
| 144 | +Deleting a person's account keeps the runs the organization's keys paid for in its owners' report, under **Deleted account**. |
addedapps/diffinery/docs/agents/overview.md+318−0View file
| @@ -1,0 +1,318 @@ | ||
| 1 | +--- | |
| 2 | +title: Agents | |
| 3 | +description: Agent accounts for AI tools and automation, how to create them and their tokens, what they can and can't do, and how to connect a coding agent. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +An agent is an account for an AI tool or an automation, owned by a person or an organization. It has its own login and tokens, acts on behalf of its owner, and is held to guardrails that keep a human in the loop on protected branches. Give each AI coding agent, review bot or script its own agent account instead of a personal token. | |
| 7 | + | |
| 8 | +## What an agent is | |
| 9 | + | |
| 10 | +- **An account of its own.** An agent has a login (for example `review-bot`), a display name and its own tokens. Everything it does is recorded under its login, with its owner alongside. | |
| 11 | +- **Owned by a person or an organization.** The owner is set when the agent is created. Security emails about the agent go to its owner (for an organization, to the organization's owners and admins). | |
| 12 | +- **Tokens only.** Agents authenticate with `dfy_` tokens over git, the REST API, the CLI and MCP. They can't have passkeys, so they can't sign in to the web UI, and they have no notification settings. | |
| 13 | +- **Never more than its owner.** An agent's role on a repository is capped by a maximum role (write by default) and by its owner's own role there. | |
| 14 | +- **Guardrails everywhere.** The same rules apply to git pushes, the REST API, MCP tools and sandboxes. See [Guardrails](#guardrails). | |
| 15 | + | |
| 16 | +## Create an agent | |
| 17 | + | |
| 18 | +Only people can create agents. To create an agent owned by an organization, you must be an owner or admin of that organization. Creating an agent or an agent token needs a sign-in from the last 2 hours; if yours is older, Diffinery asks you to [Confirm it's you](/docs/security/overview#confirming-its-you) with your passkey first. | |
| 19 | + | |
| 20 | +The agent's login follows the same rules as usernames: letters, digits, `-` and `_`, starting with a letter or digit, up to 100 characters, no dots, and not a reserved name (such as `api`, `mcp`, `settings` or `admin`). | |
| 21 | + | |
| 22 | +### In the web UI | |
| 23 | + | |
| 24 | +1. Go to **Settings → Agents** (`https://diffinery.com/settings/agents`). | |
| 25 | +2. Under **Create an agent**, fill in: | |
| 26 | + - **Agent username**, for example `review-bot`. | |
| 27 | + - **Display name** (optional), for example `Review bot`. Without one, the agent's username is shown. | |
| 28 | + - **Owner**: yourself, or an organization where you are an owner or admin. | |
| 29 | + - **Maximum role**: the highest role the agent can have on any repository (default `write`). | |
| 30 | +3. Click **Create agent**. You see `Agent created. Create a token for it below.` | |
| 31 | +4. In the agent's row, expand **Create a token for review-bot**, set the token **Name**, **Scope** and **Days** (1 to 366, default 30), and click **Create token**. | |
| 32 | +5. Copy the token from **Token for review-bot**. It is shown only once. | |
| 33 | + | |
| 34 | +### With the CLI | |
| 35 | + | |
| 36 | +Creating an agent mints its first token, and no token can create a token, so on a deployed instance create agents in the web UI (above). `diffinery agent create` works against a local development server. Listing and disabling agents work from the CLI with a personal token that has the `admin` scope and isn't restricted to repositories: | |
| 37 | + | |
| 38 | +```sh | |
| 39 | +curl -fsSL https://diffinery.com/install | bash | |
| 40 | +diffinery login --scope admin | |
| 41 | +``` | |
| 42 | + | |
| 43 | +On a local development server, create the agent like this. The command also creates its first token and prints it once: | |
| 44 | + | |
| 45 | +```sh | |
| 46 | +diffinery agent create review-bot --org acme --name "Review bot" --scope write --expires-days 90 | |
| 47 | +``` | |
| 48 | + | |
| 49 | +| Flag | Meaning | | |
| 50 | +| --- | --- | | |
| 51 | +| `-o, --org ORG` | Owned by this organization instead of you | | |
| 52 | +| `-n, --name NAME` | Display name | | |
| 53 | +| `-s, --scope SCOPE` | Token scope: `read`, `write` (default) or `admin`. Repeatable. | | |
| 54 | +| `-e, --expires-days DAYS` | Token lifetime in days (default 90) | | |
| 55 | +| `--json` | Print the agent and token as JSON | | |
| 56 | + | |
| 57 | +The output ends with `This token (expires 2027-01-11) will not be shown again. Give it to the agent as DIFFINERY_TOKEN.` The CLI creates agents with the default maximum role, `write`. | |
| 58 | + | |
| 59 | +`diffinery agent list` lists the agents you and your organizations own, and `diffinery agent disable review-bot` disables one. | |
| 60 | + | |
| 61 | +### With the REST API | |
| 62 | + | |
| 63 | +Creating an agent mints a token, so it needs a browser session: a token gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`. Send it from a page on your instance while signed in, for example from the browser console: | |
| 64 | + | |
| 65 | +```js | |
| 66 | +await fetch("/api/v3/diffinery/agents", { | |
| 67 | + method: "POST", | |
| 68 | + headers: { "Content-Type": "application/json" }, | |
| 69 | + body: JSON.stringify({ | |
| 70 | + login: "review-bot", | |
| 71 | + name: "Review bot", | |
| 72 | + org: "acme", | |
| 73 | + policy: { maxRole: "write" }, | |
| 74 | + token: { scopes: ["write"], expires_in_days: 90 }, | |
| 75 | + }), | |
| 76 | +}).then((r) => r.json()); | |
| 77 | +``` | |
| 78 | + | |
| 79 | +The response (`201`) contains the agent (`id`, `login`, `name`, `type: "Bot"`, `html_url`) and its first token (`token`, `id`, `expiresAt`). Leave out `org` to own the agent yourself, `policy.maxRole` for the default `write`, and `token` for a `write` token that lasts 90 days. | |
| 80 | + | |
| 81 | +`token.scopes` (and `scopes` when you create another token) must be a non-empty list of `read`, `write` and `admin`; leaving it out gives `["write"]`. An empty list or an unknown name returns `422 scopes must be a non-empty list of: read, write, admin`. | |
| 82 | + | |
| 83 | +The `POST` endpoints below create tokens, so they need a browser session. `DELETE` also accepts a personal token with the `admin` scope that isn't restricted to repositories. | |
| 84 | + | |
| 85 | +| Endpoint | Purpose | Who | | |
| 86 | +| --- | --- | --- | | |
| 87 | +| `GET /api/v3/diffinery/agents` | List agents owned by you and by organizations where you are an owner or admin | You | | |
| 88 | +| `POST /api/v3/diffinery/agents` | Create an agent and its first token | People | | |
| 89 | +| `POST /api/v3/diffinery/agents/{login}/tokens` | Create another token (`name`, `scopes`, `expires_in_days`) | The owner, or an owner or admin of the owning organization | | |
| 90 | +| `DELETE /api/v3/diffinery/agents/{login}` | Disable the agent and revoke its tokens | The owner, an owner or admin of the owning organization, or a site admin | | |
| 91 | + | |
| 92 | +## Tokens for agents | |
| 93 | + | |
| 94 | +Agent tokens work like personal tokens (see [Tokens](/docs/accounts/tokens)): | |
| 95 | + | |
| 96 | +- They start with `dfy_` and are sent as `Authorization: Bearer dfy_...` (or as the password for git over HTTPS). | |
| 97 | +- Each has a scope that caps the role it acts with: `read` (read), `write` (up to maintain) or `admin`. Use `write` for coding agents. A `read` token can't change anything at all: pushes, commits, comments, reviews and every MCP tool that isn't read-only get `403 this token needs the write scope`. Give `read` tokens only to agents that just look. | |
| 98 | +- Each expires after 1 to 366 days. Diffinery emails the owner before an agent token expires. | |
| 99 | +- Creating a token for an agent sends a security email to the agent's owner (for an organization, its owners and admins): `A token was created for the agent review-bot`. | |
| 100 | +- In **Settings → Agents**, each agent lists its tokens with scope and last use, and a **revoke** link. | |
| 101 | + | |
| 102 | +Give each running agent its own token, store it as a secret in the agent's environment (for example `DIFFINERY_TOKEN`), and revoke it when the agent no longer needs it. | |
| 103 | + | |
| 104 | +## Roles and access | |
| 105 | + | |
| 106 | +An agent made on a repository's **Settings → Agent setup** reaches that one repository and nothing else, whatever token it uses ([Cloud agent environments](/docs/connect/cloud-agents#the-token)). For every agent, its role on a repository is the lowest of: | |
| 107 | + | |
| 108 | +1. **Its cap on that repository.** If the agent was added as a collaborator on the repository (**Settings → Collaborators**, **Username (person or agent)**), that role. Otherwise its **Maximum role** (default `write`). | |
| 109 | +2. **Its owner's role there.** | |
| 110 | + - An agent owned by a person has at most that person's role on the repository, so it can reach exactly the repositories its owner can. | |
| 111 | + - An agent owned by an organization can reach only the organization's own repositories, and nothing outside the organization. | |
| 112 | +3. **Its token's scope**, as for any token. | |
| 113 | + | |
| 114 | +Agents rarely need more than write. Write is enough to push branches, commit files, open and review pull requests, run workflows and use sandboxes. Whatever the role, the guardrails below still apply. | |
| 115 | + | |
| 116 | +The agent's profile page (`https://diffinery.com/review-bot`) shows who it **Acts on behalf of**, its **Maximum role**, its **Status** and when it was **Created**. | |
| 117 | + | |
| 118 | +## Guardrails | |
| 119 | + | |
| 120 | +These rules apply to agents through git, the REST API, MCP and sandboxes: | |
| 121 | + | |
| 122 | +| Rule | Details | How to relax it | | |
| 123 | +| --- | --- | --- | | |
| 124 | +| No pushes to the default branch or protected branches | Pushes, `commit_files`, the contents API, the git data refs API, the merges API, update-branch and branch updates are refused with `agents may not push to protected or default branches; open a pull request`. One exception: in an empty repository (no branches yet), an agent may push the first commit of the default branch, as creating a repository with a README would, unless a protection rule covers that branch or the commit adds workflow files (those come in a pull request a person merges). | Tick **Allow agents to push directly** (`agentsMayPush`) on that branch's protection rule. | | |
| 125 | +| No merges into the default branch or protected branches | The merge blocker `agents may not merge into this branch; a human must merge`. Agents can't add pull requests to a merge queue or turn on auto-merge there either. | An agent from **Settings → Agent setup** may merge what a person approved, with **Can merge pull requests** (on by default; see [Merging](/docs/connect/cloud-agents#merging)). Or tick **Allow agents to merge pull requests** (`agentsMayMerge`) on the rule, or, so that CI decides instead of the agent, **Allow agents to land pull requests through auto-merge** (`allowAgentAutoMerge`, see [Agents landing changes](/docs/repositories/branch-protection#agents-landing-changes)); the agent then turns on [auto-merge](/docs/collaboration/pull-requests#auto-merge-for-agents-and-workflow-tokens) with `enable_auto_merge` or `PUT /repos/{owner}/{repo}/pulls/{number}/auto-merge`. | | |
| 126 | +| Pull requests opened by agents need a human approval | At least one approval from a person, even on a branch with no protection rule. When an agent has Diffinery land its pull request on the default branch or a protected branch through auto-merge or the merge queue, the approval counts only for the commit it approved. | Only for landings an agent asked for: **Let agents' auto-merges land without an approving review** (`agentAutoMergeWithoutApproval`), which lets green required checks stand in for the approval. See [Waiving approvals for agents](/docs/repositories/branch-protection#waiving-approvals-for-agents). To have Diffinery turn auto-merge on for chosen agents' pull requests by itself, name them in **Auto-merge pull requests from these agents** (`autoMergeAgents`, see [Auto-merging agents' pull requests](/docs/repositories/branch-protection#auto-merging-agents-pull-requests)), or tick the agent's own **Merge its pull requests on green**. The repository's **Settings → Merge on green** sets all of it up at once; see [Merge on green automatically](/docs/collaboration/merge-on-green). | | |
| 127 | +| Agent approvals don't count | An agent's Approve review is shown but never counts toward required approvals. An agent's Request changes review still blocks. | None. | | |
| 128 | +| No branch protection changes | `agents cannot change branch protection` | None. | | |
| 129 | +| No secret management | Setting or deleting repository or environment secrets fails with `agents cannot manage secrets`, and agents can't use the organization secrets endpoints, whatever their role. | None. | | |
| 130 | +| No variable changes | Creating, changing or deleting repository or environment Actions variables fails with `agents cannot manage variables`, whatever the agent's role. Agents with write or higher can still list and read variables. | None. | | |
| 131 | +| No environment changes | Creating, changing or deleting an environment or its deployment branch policies fails with `only repository admins (people, not agents) can manage environments`. | None. | | |
| 132 | +| Agent reports never satisfy required checks | Commit statuses and check runs an agent reports are shown on the commit, but never count toward a required status check, and aren't counted when a deployment checks the commit's statuses. A failure reported by the real source still fails. | None. Report required checks from workflows or from a person's token. | | |
| 133 | +| Workflow runs of agent code act as the agent | A run counts as coming from an agent when an agent triggered it, an agent pushed the branch or tag it runs on last, (for pull request events) an agent opened the pull request or last pushed its head branch, or (off the default branch) its code includes a commit an agent pushed that a person hasn't merged into the default branch yet, whoever pushed, tagged or dispatched on top. Re-runs of such a run keep this, whoever starts them, and so do `workflow_run` runs that follow it, since they act on what it produced. The run's jobs act as an agent: their `GITHUB_TOKEN` gets the guardrails in this table, so it can't push or merge to the default or protected branches even with `permissions: contents: write`, and their deployments count as an agent's. Agent-written code can't reach a protected branch through a workflow. | The same per-branch settings, **Allow agents to push directly** and **Allow agents to merge pull requests**. | | |
| 134 | +| No repository or organization secrets for agent runs | A run whose code came from an agent (see the row above) gets no repository or organization secrets, only its job token, so an agent can't push a workflow that sends them elsewhere. Once an agent lands a pull request on the default branch through auto-merge, runs on that branch are agent code too, so a deploy there gets no repository secrets. | A repository admin who is a person ticks **Give runs of agent code the repository's and organization's secrets** under **Settings → Actions** (`agent_runs_use_secrets`), or, tighter, keeps deploy credentials in an environment limited to protected branches (next row). See [Which runs receive secrets](/docs/actions/secrets-and-variables#which-runs-receive-secrets). | | |
| 135 | +| No environment secrets for agent runs on open environments | A job with `environment:` in a run whose code came from an agent (see the row above) gets the environment's secrets only when the environment is limited to protected branches. Pull request runs never get environment secrets. | Limit the environment to **protected branches**. | | |
| 136 | +| Agent tags don't match environment tag rules | A tag last pushed or moved by an agent never matches an environment's tag pattern, so it can't deploy to that environment. | Have a person push the tag. | | |
| 137 | +| No review dismissal | Dismissing a review fails with `agents can't dismiss reviews`, whatever the agent's role. | None. | | |
| 138 | +| No repository deletion | `agents cannot delete repositories` | None. | | |
| 139 | +| No repository transfers | `agents and workflow tokens cannot transfer repositories`. Only people move repositories to another owner. | None. | | |
| 140 | +| No push protection bypass | Only repository admins who are people can commit past secret scanning, with `git push -o allow-secrets=<reason>` or `allow_secrets` on `POST /repos/{owner}/{repo}/diffinery/commits`. An agent's push or commit that contains a credential is always refused (and `commit_files` has no bypass at all); see [Push protection](/docs/security/push-protection). | None. | | |
| 141 | +| No admin bypass of merge requirements | Merging past unmet branch rules (`"bypass_rules": true`) or past the merge queue (`"bypass_merge_queue": true`) is for repository admins who are people. An agent's merge with these flags is still refused with the usual blockers. | None. | | |
| 142 | +| No creating agents | `only people can create agents` | None. | | |
| 143 | +| Imports from GitHub only where the owner may import | A person's agent imports into the owner's own account or an organization the owner owns or administers; an organization's agent imports into that organization (`agents can import only where their owner may: ...`). Its maximum role must be at least `write`. | None. | | |
| 144 | +| No push protection bypass in imports | An agent's import always stops when old history contains something that looks like a credential, whatever it asks for. | A person who administers the target starts the import. | | |
| 145 | +| No variables, secret names or environments in imports | The import's variables steps are skipped (`skipped (agents cannot manage variables)`). | A person sets them up after the import. | | |
| 146 | +| No import retry once the repository exists | `an agent cannot retry an import after the repository was created; a person who administers <owner> must retry it` | A person who administers the target retries it. | | |
| 147 | + | |
| 148 | +The default branch is protected from agents even if it has no protection rule. Agents can still push freely to other branches, open pull requests, comment, review, run workflows, report commit statuses and check runs, create releases and start sandboxes, within their role. | |
| 149 | + | |
| 150 | +To let a trusted agent push to a release branch, for example, add a protection rule for `release/*` with **Allow agents to push directly**. See [Branch protection](/docs/repositories/branch-protection), [Checks and statuses](/docs/collaboration/checks-and-statuses) and [Environments](/docs/actions/environments). | |
| 151 | + | |
| 152 | +## How agent activity is recorded | |
| 153 | + | |
| 154 | +### Commits | |
| 155 | + | |
| 156 | +Commits that Diffinery writes for an agent are stamped with an extra header naming the agent and its owner, `diffinery-agent review-bot on-behalf-of diffy`. This covers commits made through: | |
| 157 | + | |
| 158 | +- the `commit_files` MCP tool and `POST /repos/{owner}/{repo}/diffinery/commits`; | |
| 159 | +- the contents API (`PUT` and `DELETE /repos/{owner}/{repo}/contents/{path}`); | |
| 160 | +- the git data API (`POST /repos/{owner}/{repo}/git/commits`); | |
| 161 | +- the merges API (`POST /repos/{owner}/{repo}/merges`) and updating a pull request's branch (`PUT /repos/{owner}/{repo}/pulls/{number}/update-branch`); | |
| 162 | +- merge and squash commits of pull requests the agent merges, directly or through auto-merge. | |
| 163 | + | |
| 164 | +The author and committer are the agent, for example `review-bot <review-bot@users.noreply.diffinery.com>`, unless the request sets `author` or `committer` (the contents and git data APIs accept them). The header is added either way. | |
| 165 | + | |
| 166 | +The header is part of the commit, so it is covered by the commit's SHA and travels with it. See it with `git cat-file`: | |
| 167 | + | |
| 168 | +```sh | |
| 169 | +git cat-file -p 9be41d07 | |
| 170 | +``` | |
| 171 | + | |
| 172 | +```text | |
| 173 | +tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904 | |
| 174 | +parent 51c0e2aa... | |
| 175 | +author review-bot <review-bot@users.noreply.diffinery.com> 1791883964 +0000 | |
| 176 | +committer review-bot <review-bot@users.noreply.diffinery.com> 1791883964 +0000 | |
| 177 | +diffinery-agent review-bot on-behalf-of diffy | |
| 178 | + | |
| 179 | +Fix the Contact sales link | |
| 180 | +``` | |
| 181 | + | |
| 182 | +Commits an agent makes with its own git client and pushes are stored as the client wrote them. Inside a [sandbox](/docs/agents/sandboxes), git is preconfigured to author commits as the agent. | |
| 183 | + | |
| 184 | +### Everywhere else | |
| 185 | + | |
| 186 | +- **Web UI:** agents have a bot avatar, an `agent` badge and `on behalf of <owner>` next to their name on issues, pull requests, reviews, comments and workflow runs. | |
| 187 | +- **REST API:** user objects for agents have `"type": "Bot"` (people are `"User"`, organizations `"Organization"`). `diffinery whoami` adds `[agent]`, and the MCP `whoami` tool returns `kind: "agent"` and `on_behalf_of`. | |
| 188 | +- **Webhooks:** the `sender` of an event caused by an agent has `"type": "Bot"` and `on_behalf_of` set to the owner's login. | |
| 189 | +- **Audit log:** entries for agent actions (pushes, merges and other audited actions) record the owner in `on_behalf_of`. Creating, disabling and enabling agents, and creating agent tokens, are audited (`agent.create`, `agent.disable`, `agent.enable`, `agent.token.create`). See [Audit log](/docs/security/audit-log). | |
| 190 | +- **Email:** the person who creates an agent gets a security email, `A new agent was added: review-bot`, which can't be turned off. | |
| 191 | + | |
| 192 | +## Imports from GitHub | |
| 193 | + | |
| 194 | +Agents can import a GitHub repository with the `start_github_import` MCP tool (or `POST /api/v3/diffinery/imports`, or `diffinery migrate github` with the agent's token), and follow it with `get_import` and `list_imports`. The import runs on the server and copies the Git history, issues and pull requests with their numbers, comments, reviews, labels, milestones, branch protection and releases. The agent rules: | |
| 195 | + | |
| 196 | +- The target must be somewhere the agent's owner may import: for a person's agent, the owner's own account or an organization the owner owns or administers; for an organization's agent, that organization. | |
| 197 | +- The agent's maximum role must be at least `write`, and its token needs the `write` scope. | |
| 198 | +- Push protection is never bypassed: the `secrets` option is always `stop`, so history with something that looks like a credential fails the import instead of being imported. | |
| 199 | +- Actions variables, secret names, environments and organization variables are skipped. Agents never read or set secret values. | |
| 200 | +- Once the repository exists, the agent can't retry a failed import; a person who administers the target retries it. | |
| 201 | +- The agent sees only the imports it started. | |
| 202 | + | |
| 203 | +Everything the import writes fires no workflows, webhooks or notifications, and the repository is locked against other writes until the import ends. Imported reviews never count as approvals, so an imported open pull request still needs a fresh human approval. See [Migrate from GitHub](/docs/migrate-from-github#agents-and-imports) and [Imports over MCP](/docs/agents/mcp#imports-over-mcp). | |
| 204 | + | |
| 205 | +## Disable an agent | |
| 206 | + | |
| 207 | +Disabling an agent revokes all its tokens at once. | |
| 208 | + | |
| 209 | +- **Web UI:** **Settings → Agents**, click **Disable** next to the agent. **Enable** turns it back on, but its old tokens stay revoked: create a new token. | |
| 210 | +- **CLI:** `diffinery agent disable review-bot` | |
| 211 | +- **REST API:** `DELETE /api/v3/diffinery/agents/review-bot` (returns `204`). | |
| 212 | + | |
| 213 | +To cut off one running copy without disabling the agent, revoke just its token. | |
| 214 | + | |
| 215 | +## Agent tasks, agent review and Today | |
| 216 | + | |
| 217 | +- **[Agent tasks](/docs/agents/tasks)**: on a repository's **Project** tab, give one of your agents a goal. Diffinery runs Claude Code or Codex (on their own providers, or via [OpenRouter](/docs/agents/tasks#openrouter) on a model you choose) or your own command in a sandbox as that agent, with your own model API key (saved under **Settings → Model access**, and never placed in the sandbox), then pushes a branch and opens a pull request for review. | |
| 218 | +- **[Projects](/docs/agents/projects)**: the Project tab shows a repository's tasks on a board, with a conversation, a shared memory every task reads, project instructions, and a limit on how many tasks run at once. Your own coordinator agent can turn your posts there into tasks. | |
| 219 | +- **[Agent review](/docs/agents/review)**: one of your agents reviews pull requests for bugs, security issues and logic errors, like Cursor's Bugbot. It posts inline findings and an **Agent review** check, and **Fix it** starts a task that pushes the fix. | |
| 220 | +- **[Terminals](/docs/agents/terminal)**: a shell in your browser, in a sandbox with the repository checked out, acting as one of your agents. Start Claude Code or Codex there and sign in with your own Claude or ChatGPT subscription instead of an API key, then work in it yourself or [give it a task](/docs/agents/terminal#give-it-a-task) that runs on that subscription and opens a pull request. | |
| 221 | +- **[Today](/docs/agents/today)**: the first page after signing in, also `GET /api/v3/diffinery/today` and the `today` MCP tool. It shows the pull requests that need you, your agents' open pull requests and tasks, and what merged, was released or failed in the last 24 hours. | |
| 222 | + | |
| 223 | +## Set up a coding agent | |
| 224 | + | |
| 225 | +Coding agents such as Claude Code, Cursor or a CI bot can work with Diffinery in two ways: | |
| 226 | + | |
| 227 | +- **MCP:** the agent calls tools on `https://diffinery.com/mcp` to read files, commit, open pull requests, follow CI and run commands in sandboxes, with no git client needed. Best for agents that already speak MCP. | |
| 228 | +- **git and the CLI:** the agent clones the repository, edits files locally, pushes a branch, and uses the `diffinery` CLI for pull requests and CI. | |
| 229 | + | |
| 230 | +### Recommended setup | |
| 231 | + | |
| 232 | +For an agent in a cloud sandbox, such as a Claude Code cloud environment, the repository's **Settings → Agent setup** does steps 1, 3 and 4 for you: it makes an agent that can only reach that repository, and gives you the token, setup script and environment variables to paste. See [Cloud agent environments](/docs/connect/cloud-agents). | |
| 233 | + | |
| 234 | +1. **Create an agent** owned by you or your organization, with maximum role `write`, and a `write` token. | |
| 235 | +2. **Protect the default branch:** add a branch protection rule with **Require a pull request before merging**, at least one **Required approvals**, and your CI checks in **Required status checks**. Agents can't push to it either way; the rule makes sure every change is reviewed and tested. | |
| 236 | +3. **Give the token to the agent** as an environment variable, for example `DIFFINERY_TOKEN`, never committed to a repository. | |
| 237 | +4. **Connect the agent** with MCP or git, as below. | |
| 238 | +5. **Tell the agent how to work.** For example, in your agent instructions file: work on a new branch, open a pull request with an `intent` that states the goal, wait for checks with `diffinery pr checks --watch` or `list_workflow_runs`, and fix failures before asking for review. | |
| 239 | + | |
| 240 | +### Connect over MCP | |
| 241 | + | |
| 242 | +claude.ai, Claude Code and Codex can sign in through your browser instead of using a token, and a plugin teaches Claude Code and Codex how Diffinery repositories work: see [Connect AI apps](/docs/connect). With a token, for Claude Code: | |
| 243 | + | |
| 244 | +```sh | |
| 245 | +claude mcp add --transport http diffinery https://diffinery.com/mcp \ | |
| 246 | + --header "Authorization: Bearer dfy_..." | |
| 247 | +``` | |
| 248 | + | |
| 249 | +For Cursor, add to `.cursor/mcp.json` (or `~/.cursor/mcp.json`): | |
| 250 | + | |
| 251 | +```json | |
| 252 | +{ | |
| 253 | + "mcpServers": { | |
| 254 | + "diffinery": { | |
| 255 | + "url": "https://diffinery.com/mcp", | |
| 256 | + "headers": { "Authorization": "Bearer dfy_..." } | |
| 257 | + } | |
| 258 | + } | |
| 259 | +} | |
| 260 | +``` | |
| 261 | + | |
| 262 | +`diffinery mcp-config` prints a ready-made snippet that reads the token from `${DIFFINERY_TOKEN}`. See [MCP server](/docs/agents/mcp) for other clients, the tool list and example calls. | |
| 263 | + | |
| 264 | +### Connect with git and the CLI | |
| 265 | + | |
| 266 | +```sh | |
| 267 | +export DIFFINERY_TOKEN=dfy_... | |
| 268 | +diffinery auth setup-git # git uses the token for https://diffinery.com | |
| 269 | +git clone https://diffinery.com/acme/website.git | |
| 270 | +cd website | |
| 271 | +git switch -c fix-pricing-link | |
| 272 | +# ...edit and commit... | |
| 273 | +git push -u origin fix-pricing-link | |
| 274 | +diffinery pr create --title "Fix the Contact sales link" \ | |
| 275 | + --intent "Point the Contact sales link at /contact; no other changes." | |
| 276 | +diffinery pr checks --watch # wait for this branch's pull request: exit 0 passed, 1 failed | |
| 277 | +``` | |
| 278 | + | |
| 279 | +As with `gh`, `pr view`, `pr checks`, `pr diff`, `pr comment`, `pr review`, `pr ready` and `pr merge` act on the open pull request for the current branch when you leave out its number. Without `--watch`, `diffinery pr checks` returns at once and exits `8` while checks are still pending. | |
| 280 | + | |
| 281 | +The CLI reads `DIFFINERY_TOKEN` (and `DIFFINERY_URL`, which defaults to `https://diffinery.com`) from the environment, so no `diffinery login` is needed. Without the CLI, use the token as the git password: `git clone https://x-token:dfy_...@diffinery.com/acme/website.git`. | |
| 282 | + | |
| 283 | +## Differences from GitHub | |
| 284 | + | |
| 285 | +- Agents are accounts owned by a person or an organization, rather than GitHub Apps with installations, and authenticate with ordinary `dfy_` tokens. | |
| 286 | +- Agents are held to built-in guardrails (no pushes or merges to default and protected branches, human approval for their pull requests, agent approvals not counted) that you relax per branch with `agentsMayPush`, `agentsMayMerge`, `allowAgentAutoMerge` and `agentAutoMergeWithoutApproval`; `autoMergeAgents` has Diffinery turn auto-merge on for chosen agents by itself. The guardrails also hold for workflow runs of code an agent wrote, so an agent can't use a workflow's `GITHUB_TOKEN` to get around them. | |
| 287 | +- Commit statuses and check runs reported by agents never satisfy required checks, and agents can't manage environments, secrets or Actions variables, or dismiss reviews. | |
| 288 | +- Agents can run GitHub imports, within the import rules above. | |
| 289 | +- Commits Diffinery writes for agents carry a `diffinery-agent ... on-behalf-of ...` header. | |
| 290 | + | |
| 291 | +## Troubleshooting | |
| 292 | + | |
| 293 | +| Message | Cause and fix | | |
| 294 | +| --- | --- | | |
| 295 | +| `agents may not push to protected or default branches; open a pull request` | Push to a new branch and open a pull request, or enable **Allow agents to push directly** on that branch's rule. | | |
| 296 | +| `agents may not merge into this branch; a human must merge` | A person must merge. Or turn on auto-merge (`enable_auto_merge`) on a branch whose rule allows it (`allowAgentAutoMerge`), or enable **Allow agents to merge pull requests** on the rule. | | |
| 297 | +| `needs 1 more approving review(s)` on an agent's pull request | A person with write access must approve it. Agent approvals don't count. | | |
| 298 | +| `only people can create agents` | You called the agents API with an agent's token. Use your own. | | |
| 299 | +| `agents cannot manage secrets` | A person with admin access must set or delete repository, environment and organization secrets. | | |
| 300 | +| `agents cannot manage variables` | A person with admin access must create, change or delete repository and environment variables. | | |
| 301 | +| A workflow on the agent's branch fails to push to `main` with `agents may not push to protected or default branches; open a pull request` | The run's code came from an agent, so its job token acts as the agent. Open a pull request from the branch and have a person merge it. | | |
| 302 | +| `push protection: possible secret detected (...)` from `commit_files` or the commits API | A file the agent committed looks like a credential. Remove it, or mark a test fixture with `diffinery:allow-secret`. Agents can't bypass push protection. | | |
| 303 | +| `this token needs the write scope` | The agent's token has only the `read` scope. Create a `write` token for it. | | |
| 304 | +| `this token needs the admin scope` | Creating agents or agent tokens, or disabling an agent, through the API or CLI needs your personal `admin` token. | | |
| 305 | +| `this token is limited to repositories and can't change account settings` | Managing agents needs a personal token without a repository list. | | |
| 306 | +| `scopes must be a non-empty list of: read, write, admin` | The `scopes` you sent were empty or had an unknown name. | | |
| 307 | +| `only repository admins (people, not agents) can manage environments` | A person with admin access must create or change the environment. | | |
| 308 | +| `agents can't dismiss reviews` | A person with maintain access must dismiss the review. | | |
| 309 | +| `required check 'NAME' has not run` although an agent reported it | Agent reports never satisfy required checks. Report it from a workflow or with a person's token. | | |
| 310 | +| `only organization admins can create organization agents` | You must be an owner or admin of the organization. | | |
| 311 | +| `invalid or reserved agent name` | Use letters, digits, `-` and `_`, no dots, and avoid reserved names. | | |
| 312 | +| `name already taken` | Pick another login; agents share the namespace with people and organizations. | | |
| 313 | +| `not your agent` | Only the owner, or an owner or admin of the owning organization, can manage it. | | |
| 314 | +| `repository not found` for a repository you can see | The agent's owner (or, for an organization agent, the organization) has no access to it. | | |
| 315 | +| `agents can import only where their owner may: ...` | Import into the owner's own account or an organization the owner administers; an organization's agent imports only into that organization. | | |
| 316 | +| `this agent's policy does not allow writing; an import needs at least write` | Raise the agent's **Maximum role** to `write` or higher. | | |
| 317 | +| `skipped (agents cannot manage variables)` on an import | Expected for an agent's import. A person sets variables, environments and secrets afterwards. | | |
| 318 | +| `invalid, expired or revoked token` | The token expired or was revoked, or the agent is disabled. Create a new token. | |
addedapps/diffinery/docs/agents/projects.md+142−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/agents/review.md+161−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/agents/sandboxes.md+222−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/agents/tasks.md+276−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/agents/terminal.md+149−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/agents/today.md+176−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/collaboration/checks-and-statuses.md+393−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/collaboration/issues.md+624−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/collaboration/merge-on-green.md+125−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/collaboration/merge-queue.md+321−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/collaboration/pull-requests.md+962−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/collaboration/releases.md+522−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/concepts.md+220−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/connect.md+57−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/connect/ci-status-in-claude.md+105−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/connect/claude-code.md+139−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/connect/claude.md+62−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/connect/cloud-agents.md+152−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/connect/codex.md+114−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/connect/how-sign-in-works.md+60−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/connect/other-apps.md+69−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/guides.md+16−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/guides/ai-agent-with-guardrails.md+97−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/guides/faster-ci.md+150−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/guides/mirror-from-github.md+44−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/guides/move-github-actions-workflows.md+126−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/images/agent-pull-request.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/agent-task.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/connected-apps.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/consent-claude-code.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/consent-codex.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/editor-clone-picker.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/editor-consent.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/editor-repositories.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/jetbrains-consent.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/jetbrains-get-from-vcs.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/jetbrains-plugins.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/jetbrains-repositories.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/merge-on-green.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/today.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/index.md+61−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/migrate-from-github.md+1037−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/packages/container-registry.md+593−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/quickstart.md+281−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/reference/graphql-api.md+97−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/reference/limits.md+398−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/reference/rest-api.md+890−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/reference/webhook-events.md+796−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/repositories/apps.md+119−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/repositories/branch-protection.md+509−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/repositories/browsing-code.md+331−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/repositories/clone-in-jetbrains-ides.md+114−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/repositories/clone-in-your-editor.md+133−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/repositories/git-lfs.md+194−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/repositories/git.md+355−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/repositories/overview.md+574−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/repositories/webhooks.md+352−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/security/audit-log.md+271−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/security/disclosure.md+71−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/security/overview.md+313−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/security/push-protection.md+291−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/self-hosting.md+64−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/self-hosting/architecture.md+328−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/self-hosting/configuration.md+378−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/self-hosting/deploy.md+400−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/docs/self-hosting/status-page.md+137−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/islands/build.mjs+104−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/islands/kumo.tsx+208−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/islands/terminal.ts+101−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/islands/tsconfig.json+10−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0001_init.sql+119−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0002_passkeys.sql+46−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0003_container_registry.sql+84−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0004_email.sql+60−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0007_repo_topics.sql+2−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0008_profile_appearance_avatars.sql+13−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0009_org_base_permission.sql+6−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0010_imports.sql+56−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0011_redirects.sql+34−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0012_repo_activity.sql+8−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0013_agent_tasks.sql+32−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0014_org_secret_repos.sql+9−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0015_invite_rate.sql+2−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0016_agent_tasks_repo.sql+3−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0017_account_invites.sql+4−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0018_quotas.sql+23−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0019_agent_terminals.sql+26−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0020_signup.sql+27−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0021_suspensions.sql+20−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0022_storage_quota.sql+10−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0023_upload_hash_state.sql+3−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0024_server_errors.sql+14−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0025_moderation.sql+41−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0026_account_lifecycle.sql+30−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0027_email_suppressions.sql+12−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0028_terminal_tasks.sql+3−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0029_security_reports.sql+21−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0030_agent_usage.sql+26−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0031_org_agent_policies.sql+41−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0032_mail_digests.sql+9−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0033_status_incidents.sql+27−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0034_cli_logins.sql+29−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0035_scale_indexes.sql+16−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0036_oauth.sql+57−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0037_early_access.sql+40−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0038_agent_setup.sql+26−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0039_indexnow.sql+34−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0040_indexnow_endpoint.sql+3−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0041_account_auto_merge.sql+4−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/migrations/0042_apps.sql+81−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/package.json+54−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/scripts/agent-screenshots.mjs+339−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/scripts/build-docs.mjs+352−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/scripts/docs-screenshots.mjs+145−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/scripts/make-icons.py+120−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/scripts/make-ui-icons.mjs+76−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/about.md+47−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/agents.md+134−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/blog/github-actions-on-cloudflare-containers.md+165−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/blog/index.md+14−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/built-on-cloudflare.md+144−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/compare/bitbucket.md+68−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/compare/blacksmith.md+53−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/compare/buildkite.md+70−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/compare/circleci.md+70−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/compare/depot.md+50−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/compare/gitea.md+67−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/compare/github.md+106−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/compare/gitlab.md+58−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/compare/index.md+107−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/faq.md+143−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/features.md+92−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/open-source.md+82−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/performance.md+84−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/privacy.md+107−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/site/security.md+51−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/accounts/export.ts+277−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/accounts/lifecycle.ts+203−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/accounts/workflow.ts+59−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/ProjectDO.ts+2146−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/access.ts+45−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/api.ts+342−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/coordinator.ts+218−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/digest.ts+72−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/inflight.ts+26−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/keys.ts+128−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/ledger.ts+139−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/mcp.ts+85−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/org-policy.ts+346−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/org-web.tsx+329−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/project-web.tsx+662−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/project.ts+159−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/projects.ts+251−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/proxy.ts+243−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/review-web.tsx+213−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/review.ts+380−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/reviews.ts+257−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/runtimes.ts+333−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/tasks-web.tsx+835−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/tasks.ts+392−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/terminal-web.tsx+376−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/terminals.ts+298−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/today-repo.ts+296−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/today.ts+313−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/usage.ts+456−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/agentic/web.tsx+280−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/app.ts+312−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/apps/keys.ts+192−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/apps/model.ts+152−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/apps/service.ts+278−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/apps/store.ts+674−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/auth/cli-login.ts+237−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/auth/dev.ts+40−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/auth/invite-proof.ts+30−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/auth/oauth.ts+599−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/auth/principal.ts+311−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/auth/sessions.ts+234−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/auth/signup.ts+179−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/compose.ts+18−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/docs/api-meta.ts+2189−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/docs/claude-plugins.ts+32−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/docs/pages.ts+272−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/docs/reference.ts+448−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/docs/web.tsx+430−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/env.ts+144−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/git/fsck.ts+122−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/git/protocol.ts+337−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/git/push-contents.ts+230−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/git/secret-scan.ts+124−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/http/actions-runtime.ts+638−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/http/api.ts+1597−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/http/apps.ts+382−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/http/avatars.ts+230−0View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/src/http/camo.ts+48−0View file
Diff not rendered to keep this page fast. View the file