Compare commits
4
Commits
7605efe624
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52b59bbe00 | ||
|
|
ef85d40a63 | ||
|
|
300e69d3b3 | ||
|
|
6c56c1953e |
@@ -363,11 +363,11 @@ Run it by hand with `engine/agents.py up --config agents.toml`.
|
|||||||
secret anywhere else** — not in a git remote URL, not in a project `.env`, not in a prompt.
|
secret anywhere else** — not in a git remote URL, not in a project `.env`, not in a prompt.
|
||||||
|
|
||||||
```
|
```
|
||||||
/srv/secrets/store.yaml the store: sops+age ciphertext, mode 0600
|
/secrets/store.yaml the store: sops+age ciphertext, mode 0600
|
||||||
~/.config/sops/age/keys.txt the age private key — the ONE plaintext secret, mode 0600
|
~/.config/sops/age/keys.txt the age private key — the ONE plaintext secret, mode 0600
|
||||||
```
|
```
|
||||||
|
|
||||||
`/srv/secrets/` is deliberately **not a git repo and has no remote**, so there is no path by
|
`/secrets/` is deliberately **not a git repo and has no remote**, so there is no path by
|
||||||
which a `git add`/`git push` can leak it; the store is ciphertext at rest anyway.
|
which a `git add`/`git push` can leak it; the store is ciphertext at rest anyway.
|
||||||
|
|
||||||
Read it with `engine/secrets.py` (stdlib + the `sops` binary, no Python deps):
|
Read it with `engine/secrets.py` (stdlib + the `sops` binary, no Python deps):
|
||||||
@@ -382,13 +382,40 @@ env = get_group("cc_ci_testenv") # a whole group as a dict
|
|||||||
python3 engine/secrets.py list # group/key NAMES only — never prints values
|
python3 engine/secrets.py list # group/key NAMES only — never prints values
|
||||||
python3 engine/secrets.py get tangled.cookie # one value on stdout
|
python3 engine/secrets.py get tangled.cookie # one value on stdout
|
||||||
python3 engine/secrets.py materialize tangled-session # write a runtime file from the store
|
python3 engine/secrets.py materialize tangled-session # write a runtime file from the store
|
||||||
sops /srv/secrets/store.yaml # add/edit: decrypts to $EDITOR, re-encrypts on save
|
sops /secrets/store.yaml # add/edit: decrypts to $EDITOR, re-encrypts on save
|
||||||
```
|
```
|
||||||
|
|
||||||
**Materialized files.** Some consumers read a fixed path and can't be taught otherwise (a systemd
|
**One home per secret — two shapes.**
|
||||||
`EnvironmentFile`, an ssh `IdentityFile`, nix's `authKeyFile`). Those files still exist at 0600,
|
|
||||||
but **the store is the source of truth** — `materialize` rewrites them from it. Never hand-edit a
|
*Values our code reads* live **in the store**; import this module and ask for them. Nothing is
|
||||||
materialized file: edit the store and re-materialize, or the two silently drift.
|
written to disk (`engine/.tangled-session` is gone — the tangled tools read `tangled.cookie`).
|
||||||
|
|
||||||
|
*Secrets a third party reads from a fixed path* (ssh keys, a systemd `EnvironmentFile`, nix's
|
||||||
|
`authKeyFile`, a TLS keypair) live as **real files in `/secrets/files/`, symlinked from the path
|
||||||
|
the consumer expects**:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.ssh/tangled-ed25519 -> /secrets/files/tangled-ed25519
|
||||||
|
/etc/ts-auth-key -> /secrets/files/ts-auth-key
|
||||||
|
/srv/cc-ci/.testenv -> /secrets/files/cc-ci.testenv
|
||||||
|
```
|
||||||
|
|
||||||
|
The consumer is unchanged and unaware; the file exists once, in one directory, at 0600. Do **not**
|
||||||
|
also copy such a secret into `store.yaml` — that is two sources of truth again.
|
||||||
|
|
||||||
|
For a one-off where neither shape fits, inject at run time and leave nothing behind:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 engine/secrets.py exec-env <group> -- some-command # group as env vars
|
||||||
|
python3 engine/secrets.py with-file <group.key> -- cmd -i {} # 0600 file in a private
|
||||||
|
# tmpdir, deleted on exit
|
||||||
|
```
|
||||||
|
|
||||||
|
**The symlink exception: apps that rewrite their own credential file.** An app that refreshes an
|
||||||
|
OAuth token by writing `auth.json` atomically (write-temp + rename) **replaces the symlink with a
|
||||||
|
regular file**, silently splitting the home again. `~/.local/share/opencode/auth.json` is such a
|
||||||
|
file, so it stays where it is and is deliberately *not* centralised. Before symlinking a secret,
|
||||||
|
ask whether its owner ever writes it back.
|
||||||
|
|
||||||
**Rules of thumb**
|
**Rules of thumb**
|
||||||
|
|
||||||
|
|||||||
+59
-40
@@ -6,9 +6,15 @@ remote URLs (`https://user:pass@host/...`, which `git remote -v` happily prints)
|
|||||||
in .env files, a private key at mode 0644. Anything in a repo is one `git add -A` away from
|
in .env files, a private key at mode 0644. Anything in a repo is one `git add -A` away from
|
||||||
a push. So: ONE encrypted file, OUTSIDE every git tree, and a helper every project uses.
|
a push. So: ONE encrypted file, OUTSIDE every git tree, and a helper every project uses.
|
||||||
|
|
||||||
store: /srv/secrets/store.yaml sops+age ciphertext, mode 0600
|
/secrets/store.yaml sops+age ciphertext (0600) — values our code reads
|
||||||
age key: ~/.config/sops/age/keys.txt the ONLY plaintext secret on disk, 0600
|
/secrets/files/ real files (0600) SYMLINKED from the fixed path a third
|
||||||
outside git by construction — /srv/secrets is not a repo and has no remote.
|
party insists on: ~/.ssh keys, a systemd EnvironmentFile,
|
||||||
|
nix authKeyFile, a TLS keypair
|
||||||
|
~/.config/sops/age/keys.txt the age private key, 0600
|
||||||
|
|
||||||
|
One home per secret: a value is in the store OR a file in /secrets/files, never both.
|
||||||
|
/secrets is outside every git tree — not a repo, no remote — and outside /srv, which agents
|
||||||
|
grep and walk constantly.
|
||||||
|
|
||||||
USAGE (library):
|
USAGE (library):
|
||||||
from secrets import get, get_group
|
from secrets import get, get_group
|
||||||
@@ -18,27 +24,27 @@ USAGE (library):
|
|||||||
USAGE (CLI):
|
USAGE (CLI):
|
||||||
python3 engine/secrets.py list # group/key names only, never values
|
python3 engine/secrets.py list # group/key names only, never values
|
||||||
python3 engine/secrets.py get tangled.cookie # value to stdout (careful in logs)
|
python3 engine/secrets.py get tangled.cookie # value to stdout (careful in logs)
|
||||||
python3 engine/secrets.py materialize <name> # write a runtime file a consumer needs
|
|
||||||
|
|
||||||
MATERIALIZED FILES: some consumers read a fixed path and cannot be taught otherwise (a
|
NO SECOND COPIES. A secret is never written to a second file "so something can read it" —
|
||||||
systemd EnvironmentFile, an ssh IdentityFile, `nix`'s authKeyFile). Those files still exist
|
copies drift, get committed, and widen what a stray `grep` or an attacker finds. A consumer
|
||||||
on disk at 0600, but the STORE IS THE SOURCE OF TRUTH — `materialize` rewrites them from it.
|
that insists on a path gets a SYMLINK into /secrets/files (see above), so the file still
|
||||||
Never edit a materialized file by hand; edit the store (`sops /srv/secrets/store.yaml`) and
|
exists exactly once. For a one-off, inject at run time and leave nothing behind:
|
||||||
re-materialize, or the two silently drift.
|
|
||||||
|
|
||||||
ADDING A SECRET: sops /srv/secrets/store.yaml (opens decrypted in $EDITOR, re-encrypts on save)
|
secrets.py exec-env <group> -- some-command # group as env vars, no file
|
||||||
|
secrets.py with-file <group.key> -- cmd -i {} # 0600 file in a private tmpdir,
|
||||||
|
# deleted when the command exits
|
||||||
|
|
||||||
|
Careful with symlinks: an app that rewrites its own credential file (an OAuth refresh writing
|
||||||
|
auth.json via write-temp+rename) REPLACES the symlink with a regular file and silently splits
|
||||||
|
the home again. Before symlinking, ask whether the owner ever writes it back.
|
||||||
|
|
||||||
|
ADDING A SECRET: sops /secrets/store.yaml (opens decrypted in $EDITOR, re-encrypts on save)
|
||||||
"""
|
"""
|
||||||
import json, os, subprocess, sys, pathlib
|
import json, os, subprocess, sys, pathlib, tempfile, shutil
|
||||||
|
|
||||||
STORE = os.environ.get("AO_SECRETS_STORE", "/srv/secrets/store.yaml")
|
STORE = os.environ.get("AO_SECRETS_STORE", "/secrets/store.yaml")
|
||||||
AGE_KEY = os.environ.get("SOPS_AGE_KEY_FILE", os.path.expanduser("~/.config/sops/age/keys.txt"))
|
AGE_KEY = os.environ.get("SOPS_AGE_KEY_FILE", os.path.expanduser("~/.config/sops/age/keys.txt"))
|
||||||
|
|
||||||
# name -> (path, mode). Files a consumer reads from a fixed location; see MATERIALIZED FILES.
|
|
||||||
MATERIALIZE = {
|
|
||||||
"tangled-session": ("engine/.tangled-session", 0o600), # relative to the project dir
|
|
||||||
"cc-ci-testenv": ("/srv/cc-ci/.testenv", 0o600),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _load():
|
def _load():
|
||||||
"""Decrypt the store. Fails loudly: a silent empty dict would look like 'no secrets'."""
|
"""Decrypt the store. Fails loudly: a silent empty dict would look like 'no secrets'."""
|
||||||
@@ -67,23 +73,32 @@ def get_group(name):
|
|||||||
return _load().get(name, {})
|
return _load().get(name, {})
|
||||||
|
|
||||||
|
|
||||||
def materialize(name, project_dir="."):
|
def exec_env(group, argv):
|
||||||
"""Write a runtime file from the store. Returns the path written."""
|
"""Run argv with `group`'s keys added to the environment. Nothing touches the disk."""
|
||||||
if name == "tangled-session":
|
vals = get_group(group)
|
||||||
cookie = get("tangled.cookie")
|
if not vals:
|
||||||
if not cookie:
|
sys.exit(f"no group {group!r} in the store (secrets.py list)")
|
||||||
sys.exit("store has no tangled.cookie")
|
env = {**os.environ, **{k: str(v) for k, v in vals.items()}}
|
||||||
p = pathlib.Path(project_dir) / MATERIALIZE[name][0]
|
return subprocess.call(argv, env=env)
|
||||||
body = f"TANGLED_COOKIE={cookie}\n"
|
|
||||||
elif name == "cc-ci-testenv":
|
|
||||||
p = pathlib.Path(MATERIALIZE[name][0])
|
def with_file(dotted, argv):
|
||||||
body = "".join(f"{k}={v}\n" for k, v in get_group("cc_ci_testenv").items())
|
"""Run argv with `{}` replaced by a 0600 temp file holding the value.
|
||||||
else:
|
|
||||||
sys.exit(f"unknown materialize target {name!r} — known: {', '.join(MATERIALIZE)}")
|
The file lives in a private 0700 dir and is removed when the command exits — so a consumer
|
||||||
p.parent.mkdir(parents=True, exist_ok=True)
|
that insists on a path (ssh -i, a TLS key) never leaves a lasting second copy of the secret.
|
||||||
p.write_text(body)
|
"""
|
||||||
p.chmod(MATERIALIZE[name][1])
|
val = get(dotted)
|
||||||
return str(p)
|
if val is None:
|
||||||
|
sys.exit(f"no such key: {dotted}")
|
||||||
|
d = tempfile.mkdtemp(prefix="ao-secret-") # mkdtemp is 0700
|
||||||
|
try:
|
||||||
|
p = pathlib.Path(d) / dotted.split(".")[-1]
|
||||||
|
p.write_text(val if isinstance(val, str) else json.dumps(val))
|
||||||
|
p.chmod(0o600)
|
||||||
|
return subprocess.call([a.replace("{}", str(p)) for a in argv])
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(d, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -102,12 +117,16 @@ def main():
|
|||||||
if v is None:
|
if v is None:
|
||||||
sys.exit(f"no such key: {argv[1]}")
|
sys.exit(f"no such key: {argv[1]}")
|
||||||
print(v if isinstance(v, str) else json.dumps(v))
|
print(v if isinstance(v, str) else json.dumps(v))
|
||||||
elif cmd == "materialize":
|
elif cmd in ("exec-env", "with-file"):
|
||||||
if len(argv) < 2:
|
if "--" not in argv:
|
||||||
sys.exit(f"usage: secrets.py materialize <{'|'.join(MATERIALIZE)}>")
|
sys.exit(f"usage: secrets.py {cmd} <name> -- <command...>")
|
||||||
print("wrote", materialize(argv[1], argv[2] if len(argv) > 2 else "."))
|
i = argv.index("--")
|
||||||
|
if i != 2:
|
||||||
|
sys.exit(f"usage: secrets.py {cmd} <name> -- <command...>")
|
||||||
|
run = exec_env if cmd == "exec-env" else with_file
|
||||||
|
sys.exit(run(argv[1], argv[i + 1:]))
|
||||||
else:
|
else:
|
||||||
sys.exit(f"unknown command {cmd!r} — list | get | materialize")
|
sys.exit(f"unknown command {cmd!r} — list | get | exec-env | with-file")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
---
|
||||||
|
name: codeberg-pages
|
||||||
|
description: >-
|
||||||
|
Publish a static site to Codeberg Pages, including custom domains on the new
|
||||||
|
"git-pages" server. Use when deploying a site to Codeberg Pages, setting up or
|
||||||
|
debugging a codeberg.page / custom-domain deployment, wiring the DNS records
|
||||||
|
(A/AAAA, CNAME), the _git-pages-repository TXT authorization record, or the
|
||||||
|
deploy webhook — or when a custom domain serves a TLS error / never gets a
|
||||||
|
certificate. Covers the 2025→2026 migration off the old Pages Server v2
|
||||||
|
(.domains file) to git-pages (webhook + TXT authorization).
|
||||||
|
---
|
||||||
|
|
||||||
|
# Publishing a site to Codeberg Pages
|
||||||
|
|
||||||
|
Codeberg Pages migrated from the old **Pages Server v2** (automatic deploy,
|
||||||
|
`.domains` file) to the new **git-pages** server. On git-pages a deployment is
|
||||||
|
**webhook-triggered** and a custom domain is authorized by a **TXT record**, not
|
||||||
|
by a file in the repo. If you're following older docs or a `.domains`-based
|
||||||
|
`deploy.sh`, that's why things silently don't work.
|
||||||
|
|
||||||
|
> All values below were verified against the official docs
|
||||||
|
> (<https://docs.codeberg.org/codeberg-pages/> and `.../using-custom-domain/`).
|
||||||
|
> Codeberg changes these; re-check the docs if something behaves unexpectedly.
|
||||||
|
|
||||||
|
## The mental model
|
||||||
|
|
||||||
|
1. Static site content lives on a branch named **`pages`** (per-repo site) — push
|
||||||
|
your built site there.
|
||||||
|
2. On git-pages, pushing alone does **not** deploy. A **webhook** on the repo,
|
||||||
|
pointed at the domain you want, is what triggers a deployment.
|
||||||
|
3. A custom domain is bound to the repo by a **TXT authorization record**
|
||||||
|
(`_git-pages-repository.<domain>`) plus the normal A/AAAA/CNAME records.
|
||||||
|
4. TLS (Let's Encrypt) is issued **only after the first successful deployment**.
|
||||||
|
Before that, browsers show a TLS error — that is expected, not a bug.
|
||||||
|
|
||||||
|
## Basic deploy (no custom domain, `*.codeberg.page`)
|
||||||
|
|
||||||
|
- Put the site on a `pages` branch and push it.
|
||||||
|
- Add a webhook: repo **Settings → Webhooks → Forgejo**, Target URL
|
||||||
|
`https://<username>.codeberg.page/<repository>/`, **Branch filter: `pages`**.
|
||||||
|
- (User/org site: name the repo `pages` and use Target URL
|
||||||
|
`https://<username>.codeberg.page/`.)
|
||||||
|
|
||||||
|
## Custom domain setup (git-pages)
|
||||||
|
|
||||||
|
Do all four. Missing #2 or #3 is the usual cause of "DNS looks right but the site
|
||||||
|
won't serve / no certificate."
|
||||||
|
|
||||||
|
### 1. DNS: point the domain at Codeberg
|
||||||
|
|
||||||
|
Exact values (verify against the docs — Codeberg has changed IPs before):
|
||||||
|
|
||||||
|
- **Apex domain** (`example.org`):
|
||||||
|
- `A` → `217.197.84.141`
|
||||||
|
- `AAAA` → `2a0a:4580:103f:c0de::2`
|
||||||
|
- **Subdomain** (`www.example.org`, `foo.example.org`):
|
||||||
|
- `CNAME` → `codeberg.page.` ← **note the trailing dot.**
|
||||||
|
|
||||||
|
**Trailing-dot trap:** in a zone file / most DNS UIs, a CNAME target *without* a
|
||||||
|
trailing dot is treated as relative and the zone is appended — e.g. entering
|
||||||
|
`codeberg.page` (or an old `<user>.codeberg.page`) can resolve to
|
||||||
|
`codeberg.page.example.org.`, which is broken. Always use the fully-qualified
|
||||||
|
`codeberg.page.` with the dot. (ALIAS/ANAME works where CNAME isn't allowed, but
|
||||||
|
conflicts with DNSSEC-signed zones.)
|
||||||
|
|
||||||
|
### 2. TXT authorization record (this is how git-pages maps domain → repo)
|
||||||
|
|
||||||
|
Create one **per domain** you serve:
|
||||||
|
|
||||||
|
```
|
||||||
|
_git-pages-repository.example.org. TXT "https://codeberg.org/<user>/<repo>.git"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Name: the `_git-pages-repository.` prefix on the exact domain (including each
|
||||||
|
subdomain you serve — apex and `www` each need their own if both are used).
|
||||||
|
- Value: the **HTTPS clone URL** of the repo, ending in `.git`.
|
||||||
|
- (If you deploy via **Forgejo Actions** instead of a webhook, the record is
|
||||||
|
`_git-pages-forge-allowlist.<domain>` with the same clone-URL value.)
|
||||||
|
|
||||||
|
### 3. Deploy webhook (per domain)
|
||||||
|
|
||||||
|
Repo **Settings → Webhooks → Forgejo**:
|
||||||
|
|
||||||
|
- **Target URL:** the domain itself, and **`http://` (not `https://`) for the
|
||||||
|
first deployment** — this is documented, not a mistake (the cert doesn't exist
|
||||||
|
yet). One webhook per domain, e.g. `http://example.org`, `http://foo.example.org`.
|
||||||
|
- **Branch filter:** `pages`.
|
||||||
|
- After the first successful deploy and cert issuance, you may switch the Target
|
||||||
|
URLs to `https://`.
|
||||||
|
|
||||||
|
### 4. Trigger the first deploy
|
||||||
|
|
||||||
|
**Push to the `pages` branch** (re-run your deploy script / `git push origin pages`).
|
||||||
|
The push fires the webhook, git-pages pulls and deploys, then requests a
|
||||||
|
Let's Encrypt certificate.
|
||||||
|
|
||||||
|
- **Do NOT rely on the webhook "Test delivery" button** — the official docs say it
|
||||||
|
fails by design and is not a valid way to verify or trigger a deploy. Verify by
|
||||||
|
pushing and then checking the webhook's recent-deliveries log, or just load the
|
||||||
|
site. (This corrects a common misconception that "Test delivery" triggers a deploy.)
|
||||||
|
|
||||||
|
## The `.domains` file is obsolete
|
||||||
|
|
||||||
|
Under the old Pages Server v2, a `.domains` file in the branch listed the domains
|
||||||
|
and did apex-vs-alias redirects. On git-pages it is **no longer used** — authorization
|
||||||
|
comes from the TXT record. It's harmless to leave, but you can delete it (and drop any
|
||||||
|
`.domains` handling from `deploy.sh`). Bonus: on git-pages each domain gets its **own**
|
||||||
|
deployment, so a second domain serves the site directly instead of 301-redirecting to
|
||||||
|
the primary as the old `.domains` system did.
|
||||||
|
|
||||||
|
## TLS / certificate notes
|
||||||
|
|
||||||
|
- A cert is issued **only after the first successful webhook deployment**. A TLS
|
||||||
|
error before that is expected.
|
||||||
|
- If the domain has **CAA records**, they must allow Let's Encrypt (including the
|
||||||
|
staging issuer) or the cert request is refused.
|
||||||
|
- Cert still never issues after a successful deploy → confirm the `_git-pages-repository`
|
||||||
|
TXT value exactly matches the repo's HTTPS `.git` URL, and that the webhook Target
|
||||||
|
URL matches the domain.
|
||||||
|
|
||||||
|
## Quick troubleshooting checklist
|
||||||
|
|
||||||
|
- Browser TLS error, no cert → no successful deploy yet. Check webhook deliveries;
|
||||||
|
push to `pages`; confirm webhook Target URL used `http://` for the first deploy.
|
||||||
|
- "DNS is correct but site won't serve" → missing `_git-pages-repository` TXT, or
|
||||||
|
missing/mis-branch-filtered webhook.
|
||||||
|
- CNAME resolves to `codeberg.page.<yourzone>` → missing trailing dot; set target to
|
||||||
|
`codeberg.page.`.
|
||||||
|
- CAA present → ensure Let's Encrypt is allowed.
|
||||||
|
- Old `.domains` behavior expected (redirects) → gone on git-pages; each domain now
|
||||||
|
deploys independently.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- Codeberg Pages: <https://docs.codeberg.org/codeberg-pages/>
|
||||||
|
- Using custom domains: <https://docs.codeberg.org/codeberg-pages/using-custom-domain/>
|
||||||
|
- pages-server (now in maintenance, superseded by git-pages):
|
||||||
|
<https://codeberg.org/Codeberg/pages-server>
|
||||||
+15
-10
@@ -26,15 +26,20 @@ import argparse, os, sys, urllib.request, urllib.parse, urllib.error
|
|||||||
|
|
||||||
BASE = "https://tangled.org"
|
BASE = "https://tangled.org"
|
||||||
|
|
||||||
def load_cookie(path):
|
def load_cookie(path=None):
|
||||||
if not os.path.exists(path):
|
"""The cookie lives in the encrypted store (tangled.cookie) — see engine/README.md (Secrets).
|
||||||
sys.exit(f"no cookie file at {path} — do the one-time browser login "
|
`path` is a legacy escape hatch: a TANGLED_COOKIE=... file, used only if explicitly passed."""
|
||||||
f"(see machine-docs/tangled-pr-automation.md)")
|
if path:
|
||||||
for line in open(path):
|
for line in open(path):
|
||||||
line = line.strip()
|
if line.strip().startswith("TANGLED_COOKIE="):
|
||||||
if line.startswith("TANGLED_COOKIE="):
|
return line.strip()[len("TANGLED_COOKIE="):]
|
||||||
return line[len("TANGLED_COOKIE="):]
|
sys.exit(f"{path} has no TANGLED_COOKIE= line")
|
||||||
sys.exit("cookie file present but has no TANGLED_COOKIE= line")
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import secrets as _store
|
||||||
|
c = _store.get("tangled.cookie")
|
||||||
|
if not c:
|
||||||
|
sys.exit("no tangled.cookie in the secret store — add it with: sops /secrets/store.yaml")
|
||||||
|
return c
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description="file a Tangled PR via a reused session cookie")
|
ap = argparse.ArgumentParser(description="file a Tangled PR via a reused session cookie")
|
||||||
@@ -45,7 +50,7 @@ def main():
|
|||||||
ap.add_argument("--fork", default="", help="fork repoDid for a cross-fork PR (omit for same-repo)")
|
ap.add_argument("--fork", default="", help="fork repoDid for a cross-fork PR (omit for same-repo)")
|
||||||
ap.add_argument("--title", default="")
|
ap.add_argument("--title", default="")
|
||||||
ap.add_argument("--body", default="")
|
ap.add_argument("--body", default="")
|
||||||
ap.add_argument("--cookie-file", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), ".tangled-session"))
|
ap.add_argument("--cookie-file", default=None, help="legacy: read the cookie from this file instead of the store")
|
||||||
a = ap.parse_args()
|
a = ap.parse_args()
|
||||||
|
|
||||||
cookie = load_cookie(a.cookie_file)
|
cookie = load_cookie(a.cookie_file)
|
||||||
|
|||||||
+15
-10
@@ -22,15 +22,20 @@ import argparse, html, os, re, sys, urllib.request, urllib.parse, urllib.error
|
|||||||
|
|
||||||
BASE = "https://tangled.org"
|
BASE = "https://tangled.org"
|
||||||
|
|
||||||
def load_cookie(path):
|
def load_cookie(path=None):
|
||||||
if not os.path.exists(path):
|
"""The cookie lives in the encrypted store (tangled.cookie) — see engine/README.md (Secrets).
|
||||||
sys.exit(f"no cookie file at {path} — do the one-time browser login "
|
`path` is a legacy escape hatch: a TANGLED_COOKIE=... file, used only if explicitly passed."""
|
||||||
f"(see machine-docs/tangled-pr-automation.md)")
|
if path:
|
||||||
for line in open(path):
|
for line in open(path):
|
||||||
line = line.strip()
|
if line.strip().startswith("TANGLED_COOKIE="):
|
||||||
if line.startswith("TANGLED_COOKIE="):
|
return line.strip()[len("TANGLED_COOKIE="):]
|
||||||
return line[len("TANGLED_COOKIE="):]
|
sys.exit(f"{path} has no TANGLED_COOKIE= line")
|
||||||
sys.exit("cookie file present but has no TANGLED_COOKIE= line")
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import secrets as _store
|
||||||
|
c = _store.get("tangled.cookie")
|
||||||
|
if not c:
|
||||||
|
sys.exit("no tangled.cookie in the secret store — add it with: sops /secrets/store.yaml")
|
||||||
|
return c
|
||||||
|
|
||||||
def fetch_form(edit_url, cookie):
|
def fetch_form(edit_url, cookie):
|
||||||
"""GET the htmx edit form; return (title, body) as the appview holds them."""
|
"""GET the htmx edit form; return (title, body) as the appview holds them."""
|
||||||
@@ -54,7 +59,7 @@ def main():
|
|||||||
ap.add_argument("--body", default=None)
|
ap.add_argument("--body", default=None)
|
||||||
ap.add_argument("--body-file", default=None, help="read the new body from a file (overrides --body)")
|
ap.add_argument("--body-file", default=None, help="read the new body from a file (overrides --body)")
|
||||||
ap.add_argument("--show", action="store_true", help="print current title+body and exit (no edit)")
|
ap.add_argument("--show", action="store_true", help="print current title+body and exit (no edit)")
|
||||||
ap.add_argument("--cookie-file", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), ".tangled-session"))
|
ap.add_argument("--cookie-file", default=None, help="legacy: read the cookie from this file instead of the store")
|
||||||
a = ap.parse_args()
|
a = ap.parse_args()
|
||||||
|
|
||||||
cookie = load_cookie(a.cookie_file)
|
cookie = load_cookie(a.cookie_file)
|
||||||
|
|||||||
+15
-9
@@ -13,14 +13,20 @@ import argparse, os, sys, urllib.parse, urllib.request
|
|||||||
|
|
||||||
BASE = "https://tangled.org"
|
BASE = "https://tangled.org"
|
||||||
|
|
||||||
def load_cookie(path):
|
def load_cookie(path=None):
|
||||||
if not os.path.exists(path):
|
"""The cookie lives in the encrypted store (tangled.cookie) — see engine/README.md (Secrets).
|
||||||
sys.exit(f"no cookie file at {path} — refresh it with scripts/get-tangled-cookie.py")
|
`path` is a legacy escape hatch: a TANGLED_COOKIE=... file, used only if explicitly passed."""
|
||||||
for line in open(path):
|
if path:
|
||||||
line = line.strip()
|
for line in open(path):
|
||||||
if line.startswith("TANGLED_COOKIE="):
|
if line.strip().startswith("TANGLED_COOKIE="):
|
||||||
return line[len("TANGLED_COOKIE="):]
|
return line.strip()[len("TANGLED_COOKIE="):]
|
||||||
sys.exit("cookie file present but has no TANGLED_COOKIE= line")
|
sys.exit(f"{path} has no TANGLED_COOKIE= line")
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import secrets as _store
|
||||||
|
c = _store.get("tangled.cookie")
|
||||||
|
if not c:
|
||||||
|
sys.exit("no tangled.cookie in the secret store — add it with: sops /secrets/store.yaml")
|
||||||
|
return c
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description="create a Tangled repo via a reused session cookie")
|
ap = argparse.ArgumentParser(description="create a Tangled repo via a reused session cookie")
|
||||||
@@ -28,7 +34,7 @@ def main():
|
|||||||
ap.add_argument("--description", default="")
|
ap.add_argument("--description", default="")
|
||||||
ap.add_argument("--branch", default="main", help="default branch (form default: main)")
|
ap.add_argument("--branch", default="main", help="default branch (form default: main)")
|
||||||
ap.add_argument("--domain", default="knot1.tangled.sh", help="knot to host on (radio value)")
|
ap.add_argument("--domain", default="knot1.tangled.sh", help="knot to host on (radio value)")
|
||||||
ap.add_argument("--cookie-file", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), ".tangled-session"))
|
ap.add_argument("--cookie-file", default=None, help="legacy: read the cookie from this file instead of the store")
|
||||||
a = ap.parse_args()
|
a = ap.parse_args()
|
||||||
|
|
||||||
cookie = load_cookie(a.cookie_file)
|
cookie = load_cookie(a.cookie_file)
|
||||||
|
|||||||
Reference in New Issue
Block a user