markdown · 4912 bytes Raw Blame History

Actions secrets + variables

REST surface for the ${{ secrets.NAME }} and ${{ vars.NAME }} substitutions runners apply to workflow files. Secrets carry ciphertext on the wire (NaCl sealed-box, gh-compatible); variables are plaintext.

Scopes:

  • repo:read on the read endpoints (including the public-key probe)
  • repo:write on PUT / POST / PATCH / DELETE

The org variants live under /orgs/{org}/actions/... and follow the same scope rules.

Sealed-box (secrets only)

shithub never accepts plaintext secret values over REST. Clients must encrypt with the server's X25519 public key first.

GET /api/v1/repos/{o}/{r}/actions/secrets/public-key
{
  "key_id": "kIaP4w1eTJDhRoxw",
  "key":    "MCowBQYDK2VuAyEA..."
}

key_id is a stable identifier for the public key. Clients echo it on the PUT body so the server can detect a stale local cache and reject (with HTTP 422 stale key_id) rather than silently fail to decrypt to garbage.

To encrypt:

import base64, nacl.public
pub = nacl.public.PublicKey(base64.b64decode(key))
sealed = nacl.public.SealedBox(pub).encrypt(b"my-secret-value")
print(base64.b64encode(sealed).decode())

The Go-side equivalent:

var pub [32]byte; copy(pub[:], pubKeyBytes)
ct, _ := box.SealAnonymous(nil, []byte("my-secret-value"), &pub, rand.Reader)

Secrets endpoints

GET    /api/v1/repos/{owner}/{repo}/actions/secrets/public-key
GET    /api/v1/repos/{owner}/{repo}/actions/secrets
GET    /api/v1/repos/{owner}/{repo}/actions/secrets/{name}
PUT    /api/v1/repos/{owner}/{repo}/actions/secrets/{name}
DELETE /api/v1/repos/{owner}/{repo}/actions/secrets/{name}

GET    /api/v1/orgs/{org}/actions/secrets/public-key
GET    /api/v1/orgs/{org}/actions/secrets
GET    /api/v1/orgs/{org}/actions/secrets/{name}
PUT    /api/v1/orgs/{org}/actions/secrets/{name}
DELETE /api/v1/orgs/{org}/actions/secrets/{name}

List + Get response

[
  {
    "name":       "DEPLOY_TOKEN",
    "created_at": "2026-05-12T18:00:00Z",
    "updated_at": "2026-05-12T18:00:00Z"
  }
]

The list response never carries the value (plaintext or ciphertext). This is identical to gh's behavior. To use a secret, inject it via a workflow's ${{ secrets.NAME }} reference.

PUT body

PUT /api/v1/repos/alice/demo/actions/secrets/DEPLOY_TOKEN
Content-Type: application/json
{
  "encrypted_value": "base64-of-sealed-box-output",
  "key_id":          "kIaP4w1eTJDhRoxw"
}

204 No Content on success. Errors:

Status Code-shaped meaning
400 encrypted_value is not valid base64.
422 encrypted_value is empty, or key_id is stale, or the secret name is malformed (^[A-Za-z_][A-Za-z0-9_]*$, ≤100 chars).
422 Sealed-box decode failed (likely a stale local public-key cache).
403 PAT lacks repo:write (or org admin).
503 Operator did not configure the sealed-box keypair on the server.

Server-side: the decoded plaintext is re-encrypted with the shared storage AEAD (internal/auth/secretbox) before INSERT. Plaintext never lands in postgres.

Variables endpoints

Variables are NOT secrets — they carry plaintext config and the list/get endpoints return values directly. The runner exposes them via ${{ vars.NAME }}.

GET    /api/v1/repos/{owner}/{repo}/actions/variables
POST   /api/v1/repos/{owner}/{repo}/actions/variables
GET    /api/v1/repos/{owner}/{repo}/actions/variables/{name}
PATCH  /api/v1/repos/{owner}/{repo}/actions/variables/{name}
DELETE /api/v1/repos/{owner}/{repo}/actions/variables/{name}

GET    /api/v1/orgs/{org}/actions/variables
POST   /api/v1/orgs/{org}/actions/variables
GET    /api/v1/orgs/{org}/actions/variables/{name}
PATCH  /api/v1/orgs/{org}/actions/variables/{name}
DELETE /api/v1/orgs/{org}/actions/variables/{name}

Create request

{ "name": "API_URL", "value": "https://api.example" }

Returns the row shape with created_at/updated_at:

{
  "name":       "API_URL",
  "value":      "https://api.example",
  "created_at": "2026-05-12T18:00:00Z",
  "updated_at": "2026-05-12T18:00:00Z"
}

PATCH accepts {"value": "..."} and returns the updated row.

Constraints:

  • name matches ^[A-Za-z_][A-Za-z0-9_]*$ and is 1–100 chars.
  • value is UTF-8 ≤4096 chars.

Operator setup

The sealed-box keypair is operator-supplied via SHITHUB_ACTIONS__SECRETS__BOX_PRIVATE_KEY_B64 (base64 of a 32-byte X25519 private key). Generate one with:

openssl rand -base64 32

When unset, the server generates a per-process keypair at startup and logs a loud warning. Secrets PUT against one process won't be decryptable by another — production deployments MUST configure this knob.

View source
1 # Actions secrets + variables
2
3 REST surface for the `${{ secrets.NAME }}` and `${{ vars.NAME }}`
4 substitutions runners apply to workflow files. Secrets carry
5 ciphertext on the wire (NaCl sealed-box, gh-compatible); variables
6 are plaintext.
7
8 Scopes:
9
10 - `repo:read` on the read endpoints (including the public-key probe)
11 - `repo:write` on PUT / POST / PATCH / DELETE
12
13 The org variants live under `/orgs/{org}/actions/...` and follow the
14 same scope rules.
15
16 ## Sealed-box (secrets only)
17
18 shithub never accepts plaintext secret values over REST. Clients
19 must encrypt with the server's X25519 public key first.
20
21 ```
22 GET /api/v1/repos/{o}/{r}/actions/secrets/public-key
23 ```
24
25 ```json
26 {
27 "key_id": "kIaP4w1eTJDhRoxw",
28 "key": "MCowBQYDK2VuAyEA..."
29 }
30 ```
31
32 `key_id` is a stable identifier for the public key. Clients echo it
33 on the PUT body so the server can detect a stale local cache and
34 reject (with HTTP 422 `stale key_id`) rather than silently fail to
35 decrypt to garbage.
36
37 To encrypt:
38
39 ```python
40 import base64, nacl.public
41 pub = nacl.public.PublicKey(base64.b64decode(key))
42 sealed = nacl.public.SealedBox(pub).encrypt(b"my-secret-value")
43 print(base64.b64encode(sealed).decode())
44 ```
45
46 The Go-side equivalent:
47
48 ```go
49 var pub [32]byte; copy(pub[:], pubKeyBytes)
50 ct, _ := box.SealAnonymous(nil, []byte("my-secret-value"), &pub, rand.Reader)
51 ```
52
53 ## Secrets endpoints
54
55 ```
56 GET /api/v1/repos/{owner}/{repo}/actions/secrets/public-key
57 GET /api/v1/repos/{owner}/{repo}/actions/secrets
58 GET /api/v1/repos/{owner}/{repo}/actions/secrets/{name}
59 PUT /api/v1/repos/{owner}/{repo}/actions/secrets/{name}
60 DELETE /api/v1/repos/{owner}/{repo}/actions/secrets/{name}
61
62 GET /api/v1/orgs/{org}/actions/secrets/public-key
63 GET /api/v1/orgs/{org}/actions/secrets
64 GET /api/v1/orgs/{org}/actions/secrets/{name}
65 PUT /api/v1/orgs/{org}/actions/secrets/{name}
66 DELETE /api/v1/orgs/{org}/actions/secrets/{name}
67 ```
68
69 ### List + Get response
70
71 ```json
72 [
73 {
74 "name": "DEPLOY_TOKEN",
75 "created_at": "2026-05-12T18:00:00Z",
76 "updated_at": "2026-05-12T18:00:00Z"
77 }
78 ]
79 ```
80
81 The list response **never** carries the value (plaintext or
82 ciphertext). This is identical to gh's behavior. To use a secret,
83 inject it via a workflow's `${{ secrets.NAME }}` reference.
84
85 ### PUT body
86
87 ```
88 PUT /api/v1/repos/alice/demo/actions/secrets/DEPLOY_TOKEN
89 Content-Type: application/json
90 ```
91
92 ```json
93 {
94 "encrypted_value": "base64-of-sealed-box-output",
95 "key_id": "kIaP4w1eTJDhRoxw"
96 }
97 ```
98
99 `204 No Content` on success. Errors:
100
101 | Status | Code-shaped meaning |
102 |------:|-------------------------------------------------------------------|
103 | 400 | `encrypted_value` is not valid base64. |
104 | 422 | `encrypted_value` is empty, or `key_id` is stale, or the secret name is malformed (`^[A-Za-z_][A-Za-z0-9_]*$`, ≤100 chars). |
105 | 422 | Sealed-box decode failed (likely a stale local public-key cache). |
106 | 403 | PAT lacks `repo:write` (or org admin). |
107 | 503 | Operator did not configure the sealed-box keypair on the server. |
108
109 Server-side: the decoded plaintext is re-encrypted with the shared
110 storage AEAD (`internal/auth/secretbox`) before INSERT. Plaintext
111 never lands in postgres.
112
113 ## Variables endpoints
114
115 Variables are NOT secrets — they carry plaintext config and the
116 list/get endpoints return values directly. The runner exposes them
117 via `${{ vars.NAME }}`.
118
119 ```
120 GET /api/v1/repos/{owner}/{repo}/actions/variables
121 POST /api/v1/repos/{owner}/{repo}/actions/variables
122 GET /api/v1/repos/{owner}/{repo}/actions/variables/{name}
123 PATCH /api/v1/repos/{owner}/{repo}/actions/variables/{name}
124 DELETE /api/v1/repos/{owner}/{repo}/actions/variables/{name}
125
126 GET /api/v1/orgs/{org}/actions/variables
127 POST /api/v1/orgs/{org}/actions/variables
128 GET /api/v1/orgs/{org}/actions/variables/{name}
129 PATCH /api/v1/orgs/{org}/actions/variables/{name}
130 DELETE /api/v1/orgs/{org}/actions/variables/{name}
131 ```
132
133 ### Create request
134
135 ```json
136 { "name": "API_URL", "value": "https://api.example" }
137 ```
138
139 Returns the row shape with `created_at`/`updated_at`:
140
141 ```json
142 {
143 "name": "API_URL",
144 "value": "https://api.example",
145 "created_at": "2026-05-12T18:00:00Z",
146 "updated_at": "2026-05-12T18:00:00Z"
147 }
148 ```
149
150 PATCH accepts `{"value": "..."}` and returns the updated row.
151
152 Constraints:
153
154 - `name` matches `^[A-Za-z_][A-Za-z0-9_]*$` and is 1–100 chars.
155 - `value` is UTF-8 ≤4096 chars.
156
157 ## Operator setup
158
159 The sealed-box keypair is operator-supplied via
160 `SHITHUB_ACTIONS__SECRETS__BOX_PRIVATE_KEY_B64` (base64 of a 32-byte
161 X25519 private key). Generate one with:
162
163 ```sh
164 openssl rand -base64 32
165 ```
166
167 When unset, the server generates a per-process keypair at startup
168 and logs a loud warning. Secrets PUT against one process won't be
169 decryptable by another — production deployments MUST configure
170 this knob.