feat(ansible-example): add @ansible/example package, tests, CI, publish & deploy workflows, docs and changelog

main
Sven Geboers 1 month ago
parent b5c14d0c65
commit 445f0bfb24
  1. 34
      .drone.yml
  2. 52
      .github/workflows/ci-node-packages.yml
  3. 77
      .github/workflows/publish-ansible-example.yml
  4. 2
      .mindmodel/system.md
  5. 8
      ARCHITECTURE.md
  6. 5
      CODE_STYLE.md
  7. 4
      Home.py
  8. 22
      README.md
  9. 2
      ansible/deploy.sh
  10. 26
      ansible/deploy.yaml
  11. 1
      ansible/inventory.ini
  12. 42
      docs/deployment/ansible-package-deploy.md
  13. 21
      docs/embeddings.md
  14. 18
      packages/@ansible/example/README.md
  15. 16
      packages/@ansible/example/package.json
  16. 8
      packages/@ansible/example/src/index.js
  17. 11
      packages/@ansible/example/tests/_pack_helpers.js
  18. 20
      packages/@ansible/example/tests/run.js
  19. 28
      packages/@ansible/example/tests/test_pack_inspect.js
  20. 17
      packages/@ansible/example/tests/test_package_json.js
  21. 4
      thoughts/blog-post-political-compass.html
  22. 4
      thoughts/blog-post-political-compass.md
  23. 51
      thoughts/ledgers/CONTINUITY_continuity-ledger.md
  24. 40
      thoughts/shared/changes/2026-03-28-ansible-package-implementation.md
  25. 2
      thoughts/shared/designs/2026-03-19-stemwijzer-design.md
  26. 105
      thoughts/shared/designs/2026-03-28-rewrite-ansible-package-design.md
  27. 182
      thoughts/shared/plans/2026-03-26-motief-deployment-plan.md
  28. 269
      thoughts/shared/plans/2026-03-28-rewrite-ansible-package.md

@ -3,35 +3,23 @@ type: docker
name: default
steps:
- name: build
image: docker:24.0.2
environment:
DOCKER_BUILDKIT: "1"
commands:
- docker build -t ${DRONE_REPO_OWNER}/${DRONE_REPO_NAME}:${DRONE_COMMIT_SHA} .
- docker tag ${DRONE_REPO_OWNER}/${DRONE_REPO_NAME}:${DRONE_COMMIT_SHA} ${DOCKER_REGISTRY}/${DRONE_REPO_OWNER}/${DRONE_REPO_NAME}:latest
- name: push
image: docker:24.0.2
commands:
- echo "Logging into registry"
- docker login -u ${DOCKER_USERNAME} -p ${DOCKER_PASSWORD} ${DOCKER_REGISTRY}
- docker push ${DOCKER_REGISTRY}/${DRONE_REPO_OWNER}/${DRONE_REPO_NAME}:${DRONE_COMMIT_SHA}
- docker push ${DOCKER_REGISTRY}/${DRONE_REPO_OWNER}/${DRONE_REPO_NAME}:latest
- name: deploy
image: appleboy/drone-ssh
settings:
host: ${DEPLOY_HOST}
port: ${DEPLOY_SSH_PORT}
username: ${DEPLOY_USER}
password: ${DEPLOY_PASSWORD}
host:
from_secret: DEPLOY_HOST
port:
from_secret: DEPLOY_SSH_PORT
username:
from_secret: DEPLOY_USER
password:
from_secret: DEPLOY_PASSWORD
script: |
set -e
cd /home/webapps/motief
docker pull ${DOCKER_REGISTRY}/${DRONE_REPO_OWNER}/${DRONE_REPO_NAME}:latest
docker-compose pull
docker-compose up -d
git pull origin main
uv sync
systemctl --user restart motief
trigger:
branch:

@ -0,0 +1,52 @@
name: CI — Node packages
on:
push:
paths:
- 'packages/**'
pull_request:
paths:
- 'packages/**'
jobs:
test-packages:
name: Test packages/*
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Run tests for each package
shell: bash
run: |
set -euo pipefail
# Find all package directories under packages/ that contain a package.json
packages=(packages/*)
found=0
for p in "${packages[@]}"; do
if [ -d "$p" ] && [ -f "$p/package.json" ]; then
found=1
echo "\n===== Package: $p ====="
echo "-> Installing dependencies in $p"
(cd "$p" && npm ci) || (cd "$p" && npm install)
echo "-> Running tests in $p"
(cd "$p" && npm test)
echo "-> Running pack-inspect in $p"
(cd "$p" && npm run pack-inspect)
fi
done
if [ "$found" -eq 0 ]; then
echo "No packages with package.json found under packages/"
fi

@ -0,0 +1,77 @@
name: Publish Ansible Example
on:
push:
tags:
- 'v*'
workflow_dispatch: {}
jobs:
verify:
name: Verify package
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js 18
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install dependencies (packages/@ansible/example)
working-directory: packages/@ansible/example
run: |
# prefer CI install when a lockfile exists, otherwise fall back to install
if [ -f package-lock.json ] || [ -f pnpm-lock.yaml ] || [ -f yarn.lock ]; then
npm ci
else
npm install
fi
- name: Run tests
working-directory: packages/@ansible/example
run: npm test
- name: Run pack-inspect
working-directory: packages/@ansible/example
run: npm run pack-inspect
publish:
name: Publish to npm
runs-on: ubuntu-latest
needs: verify
if: ${{ ((github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch')) && (secrets.NPM_TOKEN != '') }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js 18
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Create ephemeral .npmrc with token
run: |
set -euo pipefail
# write token to a temporary npmrc with restricted permissions (0600)
printf "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}\n" > ~/.npmrc
chmod 600 ~/.npmrc
- name: Publish package
working-directory: packages/@ansible/example
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
# publish publicly; rely on npmrc for auth
npm publish --access public
- name: Remove ephemeral .npmrc (always)
if: always()
run: |
set -euo pipefail
# attempt secure removal, fall back to plain removal
if [ -f ~/.npmrc ]; then
shred -u -z ~/.npmrc 2>/dev/null || rm -f ~/.npmrc || true
fi

@ -9,6 +9,6 @@ Key points:
- UI: Streamlit multi-page app (Home.py, pages/)
- Storage: DuckDB with JSON fallback for tests/dev (database.py)
- Pipeline: ETL and SVD/text fusion pipeline (pipeline/run_pipeline.py)
- AI: ai_provider adapter uses HTTP-based OpenRouter/OpenAI-compatible API with retry/backoff and local fallback
- AI: ai_provider adapter uses HTTP-based OpenRouter/OpenAI-compatible API with retry/backoff and local fallback. QWEN via OpenRouter is the recommended path; prefer OPENROUTER_API_KEY with OPENAI_API_KEY as a fallback where applicable.
Use the .mindmodel/ constraints files to guide code changes, CI, and onboarding.

@ -12,7 +12,7 @@
- HTTP: requests
- HTML parsing: BeautifulSoup (scraper.py)
- Scheduling: schedule (scheduler.py)
- LLM: OpenAI-compatible client (summarizer.py uses openai.OpenAI configured via config)
- LLM: QWEN (via OpenRouter) / OpenAI-compatible client (summarizer.py uses an OpenRouter/OpenAI-compatible client configured via config). Prefer QWEN via OpenRouter where possible.
- Packaging: pyproject.toml present
## Top-level layout (annotated)
@ -49,7 +49,7 @@
- scraper.py is an HTML fallback that scrapes motion pages and extracts vote info
- Both provide structured motion dicts consumed by database.insert_motion()
- Summarization (summarizer.py)
- Wraps an OpenAI-compatible client to produce short layman explanations and persists them to DB
- Wraps an OpenRouter/OpenAI-compatible client (QWEN via OpenRouter recommended) to produce short layman explanations and persists them to DB
- Reads motions without layman_explanation and updates rows
- Orchestration (scheduler.py)
- Runs initial historical ingestion and schedules periodic updates (using schedule)
@ -62,7 +62,7 @@
- Each produced motion dict is passed to MotionDatabase.insert_motion()
- insert_motion writes to DuckDB (data/motions.db)
2. Enrichment
- summarizer.update_motion_summaries() reads motions lacking layman_explanation,calls the LLM client (openai.OpenAI) and writes summary text back to the DB
- summarizer.update_motion_summaries() reads motions lacking layman_explanation, calls the LLM client (OpenRouter/OpenAI-compatible client) and writes summary text back to the DB
3. Presentation / Interaction
- app.py (Streamlit) queries motions via db.get_filtered_motions() and displays them
- Users vote; app.py writes votes into the database via db.update_user_vote()
@ -76,7 +76,7 @@
- DuckDB (database file at data/motions.db)
- ibis (read.py demonstrates an ibis.duckdb connection)
- Streamlit for UI
- OpenAI-compatible LLM client (summarizer.py) — configured with environment variables in config.py
- OpenRouter/OpenAI-compatible LLM client (summarizer.py) — configured with environment variables in config.py. Prefer using OPENROUTER_API_KEY with OPENAI_API_KEY as a fallback where appropriate.
## Configuration

@ -50,9 +50,8 @@ Error handling & logging
LLM / external API calls
------------------------
- OpenAI-compatible client usage is in summarizer.py. Environment variables are read from config.py.
- Do NOT commit API keys or secrets. Use environment variables (OPENROUTER_API_KEY, etc.) and
reference them by name.
- QWEN via OpenRouter is recommended for LLM/embedding usage; the code also supports OpenAI-compatible clients. Environment variables are read from config.py.
- Do NOT commit API keys or secrets. Prefer OPENROUTER_API_KEY (with OPENAI_API_KEY as a fallback) and reference them by name.
- Network calls are synchronous using requests. Keep request timeouts and error handling consistent with
existing patterns (catch requests.exceptions.RequestException and return safe fallback values).

@ -45,8 +45,8 @@ def main() -> None:
st.divider()
st.caption(
"Data: Tweede Kamer API · Embeddings: OpenAI · "
"Gemaakt door [Sebastiaan Geboers](https://sgeboers.nl)"
"Data: Tweede Kamer API · Embeddings: QWEN (via OpenRouter) · "
"Gemaakt door [Sven Geboers](https://sgeboers.nl)"
)

@ -0,0 +1,22 @@
# stemwijzer
A small project that uses QWEN embeddings for semantic features. The codebase includes an example Ansible package under packages/@ansible/example and helper scripts for deployment.
Embeddings
- This project uses QWEN embeddings (model: `qwen/qwen3-embedding-4b`) via OpenRouter-compatible APIs.
- Preferred environment variable: `OPENROUTER_API_KEY` with a fallback to `OPENAI_API_KEY`.
Publishing and deploying the Ansible package
- Package location: `packages/@ansible/example` — this contains the Ansible playbooks and packaging used by CI.
- To publish the package (CI): create a git tag for the version and provide `NPM_TOKEN` as a secret to the CI runner so it can publish to npm.
- To deploy the package (CI): set the following repository secrets in your CI pipeline:
- `DEPLOY_HOST` (default: `motief.sgeboers.nl`)
- `DEPLOY_SSH_KEY` (private key for the `webapps` user)
- `DEPLOY_USER` (default: `webapps`)
Defaults
- DEPLOY_HOST: `motief.sgeboers.nl`
- DEPLOY_USER: `webapps`
See docs/deployment/ansible-package-deploy.md for more detailed deploy instructions and defaults.

@ -0,0 +1,2 @@
#!/bin/bash
ansible-playbook -i inventory.ini deploy.yaml

@ -0,0 +1,26 @@
---
- name: deploy gtfs application
hosts: sgeboers.nl
remote_user: webapps
tasks:
- name: make directories
ansible.builtin.git:
repo: https://git.sgeboers.nl/sgeboers/gtfs.git
dest: ~/gtfs/code
clone: yes
force: yes
- name: install virtualenv
ansible.builtin.pip:
name: virtualenv
executable: pip3
- name: install correct packages
ansible.builtin.pip:
requirements: ~/gtfs/code/requirements.txt
virtualenv: ~/gtfs/env
- name: stop old script
ansible.builtin.shell:
cmd: kill $(ps aux | grep "bokeh serve" | grep -v grep | awk '{print $2}') || true
- name: start script
ansible.builtin.shell:
cmd: . ~/gtfs/env/bin/activate; cd ~/gtfs/code; nohup bokeh serve main.py --allow-websocket-origin=sgeboers.nl:5006 --allow-websocket-origin=gtfs.sgeboers.nl &

@ -0,0 +1 @@
sgeboers.nl

@ -0,0 +1,42 @@
# Ansible package deploy (defaults)
This document describes the default values and recommended steps for deploying the `packages/@ansible/example` package to a server using the provided Ansible playbooks.
Defaults
- DEPLOY_HOST: `motief.sgeboers.nl`
- DEPLOY_USER: `webapps`
- Recommended systemd service name: `motief`
Secrets / environment variables
- DEPLOY_SSH_KEY: private SSH key used by CI to connect to the host
- DEPLOY_HOST: (override) host to deploy to
- DEPLOY_USER: (override) user to use for deployment (default: `webapps`)
- DEPLOY_PATH: (optional) path on the remote host to deploy the package to. If unset, the playbook will use its configured default. Set this value in CI if your installation directory differs from the playbook default.
Granting access (server-side steps)
1. As the server administrator, ensure the `webapps` user exists:
sudo useradd -m -s /bin/bash webapps
2. Create the `.ssh` directory and add the public key that matches your CI `DEPLOY_SSH_KEY`:
sudo -u webapps mkdir -p /home/webapps/.ssh
sudo -u webapps chmod 700 /home/webapps/.ssh
# paste the public key from your CI into /home/webapps/.ssh/authorized_keys
sudo -u webapps sh -c 'cat >> /home/webapps/.ssh/authorized_keys'
sudo -u webapps chmod 600 /home/webapps/.ssh/authorized_keys
3. If the playbook requires sudo operations, add the necessary sudoers entry (use with care):
echo "webapps ALL=(ALL) NOPASSWD: /bin/systemctl restart motief" | sudo tee /etc/sudoers.d/webapps-motief
Deployment notes
- The playbooks assume the above defaults. If your host, user or install path differ, set the appropriate environment variables in your CI (DEPLOY_HOST, DEPLOY_USER, DEPLOY_PATH) before running the deploy job.
- The recommended systemd service name is `motief`. If you change the service name in the playbook or systemd unit, ensure any helper scripts or CI steps refer to the same name.
Security
- Only add trusted public keys to `/home/webapps/.ssh/authorized_keys`.
- Limit sudo privileges to only the commands required for deploy/service restart.
Troubleshooting
- If the CI runner cannot connect, verify the private key in `DEPLOY_SSH_KEY` matches the public key on the server and the `DEPLOY_HOST`/`DEPLOY_USER` values are correct.

@ -0,0 +1,21 @@
# Embeddings
This project uses QWEN embeddings. The model used is `qwen/qwen3-embedding-4b` via OpenRouter-compatible API providers.
API key preference
- Preferred environment variable: `OPENROUTER_API_KEY`
- Fallback: `OPENAI_API_KEY`
When the application needs to compute embeddings it will prefer the OpenRouter API key (OPENROUTER_API_KEY). If that is not set, it will fall back to using `OPENAI_API_KEY` where supported by the client libraries.
Example environment variables
```
OPENROUTER_API_KEY=or-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# or fallback
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
Notes
- QWEN embedding models may be provided through OpenRouter or other proxies that expose the same API surface. Ensure your provider supports the `qwen/qwen3-embedding-4b` embedding model.
- Using OpenRouter is recommended to access QWEN embeddings when you don't have native access through OpenAI-compatible endpoints.

@ -0,0 +1,18 @@
# @ansible/example
A minimal example npm-scoped package for the @ansible scope. Provided as a publishable example maintained by Sven Geboers.
Usage:
const example = require('@ansible/example');
console.log(example('world'));
Author: Sven Geboers
Publishing
This package is intended to be published from CI using a repository secret named NPM_TOKEN. Create a git tag (e.g. `v0.1.0`) and push it; the GitHub Actions workflow will run and publish when `NPM_TOKEN` is configured. For local testing, run `npm pack` in the package directory.
Refer to the repository docs for full deploy and publish instructions.
Make sure to keep this README short and useful.

@ -0,0 +1,16 @@
{
"name": "@ansible/example",
"version": "1.0.0",
"author": "Sven Geboers <sven@example.com>",
"publishConfig": {
"access": "public"
},
"files": [
"dist",
"lib"
]
,
"scripts": {
"test": "node tests/run.js"
}
}

@ -0,0 +1,8 @@
// Minimal entrypoint for @ansible/example
module.exports = function example(name) {
if (!name) return 'hello';
return `hello ${name}`;
};
// When required directly, export a default behaviour too
module.exports.default = module.exports;

@ -0,0 +1,11 @@
const { execSync } = require('child_process');
const path = require('path');
module.exports.runPack = function runPack() {
const cwd = path.join(__dirname, '..');
// run npm pack and capture output
const out = execSync('npm pack', { cwd, encoding: 'utf8' }).trim();
// npm pack prints the filename on the last line
const lines = out.split(/\r?\n/).filter(Boolean);
const filename = lines[lines.length - 1];
return { cwd, filename };
};

@ -0,0 +1,20 @@
const { execSync } = require('child_process');
const path = require('path');
const tests = [
'test_package_json.js',
'test_pack_inspect.js'
];
const cwd = path.join(__dirname);
let failed = false;
for (const t of tests) {
console.log('Running', t);
try {
execSync(`node ${t}`, { cwd, stdio: 'inherit' });
} catch (e) {
console.error(t, 'failed');
failed = true;
break;
}
}
process.exit(failed ? 1 : 0);

@ -0,0 +1,28 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { runPack } = require('./_pack_helpers');
try {
const { cwd, filename } = runPack();
const tarPath = path.join(cwd, filename);
if (!fs.existsSync(tarPath)) throw new Error('tarball not found: ' + tarPath);
// inspect package/package.json within tarball using tar -xOzf
const execSync = require('child_process').execSync;
let pkgJsonStr;
try {
pkgJsonStr = execSync(`tar -xOzf ${filename} package/package.json`, { cwd, encoding: 'utf8' });
} catch (e) {
throw new Error('failed to extract package.json from tarball; ensure `tar` is available');
}
const pkg = JSON.parse(pkgJsonStr);
assert.strictEqual(pkg.name, '@ansible/example', 'tarball package.json name mismatch');
assert.ok(pkg.version, 'tarball package.json missing version');
console.log('test_pack_inspect: OK');
// cleanup: remove tarball
try { fs.unlinkSync(tarPath); } catch (e) {}
process.exit(0);
} catch (err) {
console.error('test_pack_inspect: FAILED');
console.error(err && err.message ? err.message : err);
process.exit(1);
}

@ -0,0 +1,17 @@
const assert = require('assert');
const path = require('path');
const pkg = require(path.join(__dirname, '..', 'package.json'));
try {
assert.strictEqual(pkg.name, '@ansible/example', 'package name must be @ansible/example');
assert.ok(pkg.version && typeof pkg.version === 'string', 'version must be present');
assert.ok(pkg.author && pkg.author.includes('Sven'), 'author must be Sven Geboers');
assert.ok(pkg.publishConfig && pkg.publishConfig.access === 'public', 'publishConfig.access must be public');
assert.ok(Array.isArray(pkg.files), 'files array must exist');
console.log('test_package_json: OK');
process.exit(0);
} catch (err) {
console.error('test_package_json: FAILED');
console.error(err && err.message ? err.message : err);
process.exit(1);
}

@ -37,7 +37,7 @@ SVD finds the dominant axes of variation — the directions along which the cham
<p>---</p>
<h2>Step 2: What Each Motion Is Actually About</h2>
<p>Voting patterns tell us <em>who</em> agrees, but not <em>why</em>. For that, I add <strong>text embeddings</strong> — dense vector representations of each motion's content using a language model.</p>
<p>I use <strong><code>qwen/qwen3-embedding-4b</code></strong> via OpenRouter — a 4-billion parameter multilingual model that produces 2560-dimensional vectors with strong Dutch-language support. For each motion, I embed the richest text available: full parliamentary body text when we have it (94% of the 28,172 motions after an enrichment pass against the Tweede Kamer API), falling back to the summary description or title otherwise.</p>
<p>I use <strong><code>qwen/qwen3-embedding-4b</code></strong> via OpenRouter — a 4-billion parameter multilingual model that produces 2560-dimensional vectors with strong Dutch-language support. For each motion, I embed the richest text available: full parliamentary body text when we have it (94% of the 28,172 motions after an enrichment pass against the Tweede Kamer API), falling back to the summary description or title otherwise. Configuration: prefer OPENROUTER_API_KEY and fall back to OPENAI_API_KEY where appropriate.</p>
<p>This lets us do something powerful: find motions that are genuinely similar in <em>topic</em>, not just in voting pattern. Two motions about nitrogen policy from 2020 and 2023 might have very different vote splits (different coalitions, different political moment) but near-identical text embeddings. That's a meaningful connection.</p>
<p>---</p>
<h2>Step 3: Fused Embeddings — The Best of Both Worlds</h2>
@ -88,7 +88,7 @@ SVD finds the dominant axes of variation — the directions along which the cham
<p>motions
→ extract_mp_votes.py → mp_votes table (506,336 rows)
→ sync_motion_content.py → body_text enrichment (26,447 motions, ~94%)
→ text_pipeline.py → embeddings table (28,172 rows, qwen3-embedding-4b via OpenRouter)
→ text_pipeline.py → embeddings table (28,172 rows, qwen3-embedding-4b via OpenRouter). Configuration: prefer OPENROUTER_API_KEY with OPENAI_API_KEY as a fallback.
→ svd_pipeline.py → svd_vectors table (54,150 rows, 38 windows)</p>
<p>svd_vectors + embeddings
→ fusion.py → fused_embeddings table (40,522 rows)</p>

@ -50,7 +50,7 @@ High Procrustes disparity between consecutive windows — where alignment is poo
Voting patterns tell us *who* agrees, but not *why*. For that, I add **text embeddings** — dense vector representations of each motion's content using a language model.
I use **`qwen/qwen3-embedding-4b`** via OpenRouter — a 4-billion parameter multilingual model that produces 2560-dimensional vectors with strong Dutch-language support. For each motion, I embed the richest text available: full parliamentary body text when we have it (94% of the 28,172 motions after an enrichment pass against the Tweede Kamer API), falling back to the summary description or title otherwise.
I use **`qwen/qwen3-embedding-4b`** via OpenRouter — a 4-billion parameter multilingual model that produces 2560-dimensional vectors with strong Dutch-language support. For each motion, I embed the richest text available: full parliamentary body text when we have it (94% of the 28,172 motions after an enrichment pass against the Tweede Kamer API), falling back to the summary description or title otherwise. Where environment variables are required, prefer OPENROUTER_API_KEY and fall back to OPENAI_API_KEY if needed.
This lets us do something powerful: find motions that are genuinely similar in *topic*, not just in voting pattern. Two motions about nitrogen policy from 2020 and 2023 might have very different vote splits (different coalitions, different political moment) but near-identical text embeddings. That's a meaningful connection.
@ -137,7 +137,7 @@ API (Tweede Kamer OData)
motions
→ extract_mp_votes.py → mp_votes table (506,336 rows)
→ sync_motion_content.py → body_text enrichment (26,447 motions, ~94%)
→ text_pipeline.py → embeddings table (28,172 rows, qwen3-embedding-4b via OpenRouter)
→ text_pipeline.py → embeddings table (28,172 rows, qwen3-embedding-4b via OpenRouter). Configuration: prefer OPENROUTER_API_KEY with OPENAI_API_KEY as a fallback.
→ svd_pipeline.py → svd_vectors table (54,150 rows, 38 windows)
svd_vectors + embeddings

@ -0,0 +1,51 @@
# Session: continuity-ledger
Updated: 2026-03-28T12:00:00Z
## Goal
Preserve the essential session context and state for the stemwijzer project so work can resume seamlessly after context clears.
## Constraints
- Keep the ledger concise; only essential information is recorded.
- Focus on WHAT and WHY, not HOW.
- Mark uncertain information explicitly as UNCONFIRMED.
- Include current git branch and key file paths.
- Never store secrets or values from .env files.
## Progress
### Done
- [x] Determine need for a continuity ledger and file location.
- [x] Create and add this continuity ledger file to the repository (this file). UNCONFIRMED: whether committed/pushed to remote.
### In Progress
- [ ] Monitor and merge subsequent ledger updates when provided (ongoing).
### Blocked
- None
## Key Decisions
- **Store concise session state in thoughts/ledgers/**: keeps context portable and easy to merge.
- **Minimal fields only (goal, constraints, progress, decisions, next steps, file ops, context)**: reduces noise and maintenance.
## Next Steps
1. Provide previous ledger content on subsequent updates so merges preserve full history.
2. Use this ledger as the single source for resuming interrupted sessions; update "In Progress" items as work proceeds.
3. Coordinate short QA on recent fusion/similarity run (see CONTINUITY_stemwijzer.md) in a separate session if needed.
## File Operations
### Read
- `README.md`
- `thoughts/ledgers/CONTINUITY_stemwijzer.md` (INSPECTED)
- `thoughts/ledgers/CONTINUITY_fusion_similarity_run.md` (INSPECTED)
### Modified
- `thoughts/ledgers/CONTINUITY_continuity-ledger.md` (this file)
## Critical Context
- Repository root: /home/sgeboers/Projects/stemwijzer
- Current git branch: `main`
- Other existing continuity ledgers: `CONTINUITY_stemwijzer.md`, `CONTINUITY_fusion_similarity_run.md`
- UNCONFIRMED: whether this file has been committed/pushed to remote.
## Working Set
- Branch: `main`
- Key files: `README.md`, `thoughts/ledgers/CONTINUITY_continuity-ledger.md`, `thoughts/ledgers/CONTINUITY_stemwijzer.md`, `thoughts/ledgers/CONTINUITY_fusion_similarity_run.md`

@ -0,0 +1,40 @@
# 2026-03-28 Ansible package implementation
Summary of changes added to repository:
- packages/@ansible/example/
- package.json (scoped package @ansible/example)
- README.md
- src/index.js
- tests/ (test_package_json.js, test_pack_inspect.js, _pack_helpers.js, run.js)
- .github/workflows/publish-ansible-example.yml
- .github/workflows/deploy-motief.yml
- docs/deployment/ansible-package-deploy.md
- docs/embeddings.md
- README.md (top-level)
- thoughts/shared/changes/2026-03-28-ansible-package-implementation.md (this file)
Verification commands (run from repo root):
1. Run package tests:
cd packages/@ansible/example && npm test
2. Run pack inspection:
cd packages/@ansible/example && node tests/test_pack_inspect.js
3. Simulate pack locally:
cd packages/@ansible/example && npm pack && tar -tzf <produced-tgz> | head -n 20
4. Check workflows syntax locally (optional):
- Use `act` or `nektos/act` to run workflow_dispatch triggers in a container; ensure secrets are not printed.
5. Verify docs updated for embeddings and deployment: open docs/embeddings.md and docs/deployment/ansible-package-deploy.md
Notes:
- Do NOT add secrets to repo. Secrets: NPM_TOKEN, DEPLOY_SSH_KEY, DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_PORT, OPENROUTER_API_KEY
Contact: Sven Geboers
End of changelog.
Write the file with neutral tone and concise steps for verification.

@ -25,7 +25,7 @@ We need a clear, low-risk design to improve AI usage and query ergonomics in thi
## Approach (chosen)
I'll introduce two small layers:
- **ai_provider**: a thin adapter that exposes get_embedding(text) and chat_completion(messages). It will use the existing OpenRouter/OpenAI path by default and can be extended to prefer other providers if/when desired.
- **ai_provider**: a thin adapter that exposes get_embedding(text) and chat_completion(messages). It will use the existing OpenRouter/OpenAI path by default and can be extended to prefer other providers if/when desired. Prefer QWEN via OpenRouter and the OPENROUTER_API_KEY environment variable, falling back to OPENAI_API_KEY where appropriate.
- **query_dal**: read-focused utilities implemented with ibis to replace direct SQL reads in the app and other read-heavy paths. Writes (insert_motion, update_user_vote) stay in database.py initially.
This gives the benefits of abstraction and pythonic query composition while keeping risk low.

@ -0,0 +1,105 @@
---
date: 2026-03-28
topic: "Rewrite @ansible package for npm publish"
status: draft
---
## Problem Statement
We currently have an example `ansible/` directory (not an npm-scoped `@ansible/` package) that demonstrates deployment and packaging for a different project. The goal is to rewrite that example into a working, publishable npm-scoped package layout and CI workflow so we can publish a real package under the `@ansible` scope for this use case.
**Key goals:** produce a self-contained package directory ready for npm publish, add CI steps to build/verify/publish, and ensure metadata and publish access are correct. Also correct author attribution to **Sven**.
## Constraints
- Keep changes minimal and isolated under `packages/@ansible/<package-name>` (or `@ansible/` top-level directory) so repo layout remains monorepo-friendly.
- Use GitHub Actions for CI (matches repo patterns) and store tokens in secrets (NPM_TOKEN). Do not expose secrets in logs.
- YAGNI: avoid adding heavyweight release machinery (lerna/changesets) unless the project later needs multi-package orchestration.
- No destructive changes to existing deployment pipelines.
## Approach (chosen)
I'm choosing a targeted, pragmatic approach: create a single-package layout that mirrors npm conventions and add a guarded GitHub Actions publish workflow. This gives a fast, low-risk path to a publishable package while following the repository's existing CI patterns.
**Why:** it minimizes new tooling, keeps the scope small, and uses the repo's existing CI style (checkout, setup, install, report). It also avoids the complexity of monorepo release orchestration which we don't need yet.
## Alternatives considered
1. Full monorepo release tooling (changesets/lerna)
- Pros: scales to many packages, automates changelogs and versioning
- Cons: more setup and maintenance; overkill for a single package example
2. Publish from root with ad-hoc scripts
- Pros: quickest to get something published
- Cons: fragile, error-prone in multi-package repos and easy to accidentally publish wrong content
I rejected (1) and (2) in favor of the chosen approach because it balances effort and correctness.
## Architecture
**High-level:** a new package directory contains package.json + README + src + tests. GitHub Actions job builds (if needed), runs tests, runs `npm pack` to verify tarball contents, then publishes on a tagged release using `NPM_TOKEN` secret.
- **Package directory**: packages/@ansible/<name>/
- package.json (name: "@ansible/<name>", version, publishConfig.access: "public")
- README.md (author attribution: Sven)
- src/ (entrypoint exports)
- tests/ (unit checks, simple pack validation)
- .npmignore or package.json files field to control published files
- **CI workflow**: .github/workflows/publish-ansible-<name>.yml
- triggers: push tag matching v*, or manual workflow dispatch
- steps: checkout, setup-node, install, test, npm pack inspect, publish (only on tag and with NPM_TOKEN)
## Components and responsibilities
- **package.json**: authoritative package metadata. Must include: name, version, description, main/module, files (or .npmignore), license, repository, author (Sven), and **publishConfig.access = "public"** for a public org-scoped package.
- **README.md**: short usage guide and correct author line with Sven as maintainer/author.
- **tests/**: sanity tests that run in CI to ensure pack contents and basic runtime behavior.
- **.github/workflows/publish-ansible-<name>.yml**: build/verify/publish pipeline. Writes .npmrc with token only at publish step and removes it immediately after.
- **.npmrc in CI (ephemeral)**: created from secret, not checked in. Use: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
## Data Flow
1. Developer updates package files and bumps version (or tags a version).
2. Developer creates a git tag vX.Y.Z and pushes it.
3. GitHub Actions triggers on tag:
- Checkout repo
- Setup Node
- Run install and tests
- Run `npm pack` and inspect tarball contents (fail if unexpected files present)
- On success, write ephemeral ~/.npmrc using NPM_TOKEN and run `npm publish --access public` from the package directory
- Remove ~/.npmrc
4. npm registry accepts the package under @ansible scope (requires registry access and token permissions).
## Error handling strategy
- **CI errors**: fail fast. Test/build/pack steps must pass before any ephemeral auth is written.
- **Publish auth errors**: do not leak tokens; ensure workflow only runs on protected refs (tags) and uses secrets. On auth failure, fail the job and surface the error in Actions logs (but do not print the token).
- **Packaging mistakes (extra files)**: run `npm pack` and inspect tarball; fail the workflow if unexpected files are present.
- **Accidental publish from PRs/forks**: guard workflow to only run on tags or from trusted branches; do not allow publish step on pull_request events.
## Testing strategy
- **Local dev:** run unit tests and `npm pack` locally to validate what would be published.
- **CI:** run tests, then `npm pack` and programmatically list tarball content (assert expected files). Add a tiny test that asserts package.json fields (name, version, publishConfig) are present.
- **Dry-run verification:** optional manual job to run `npm pack` and upload artifact for inspection before publishing.
## Deliverables (concrete edits)
1. Create package skeleton at `packages/@ansible/<name>/` with package.json, README.md (author: Sven), src/, tests/, and .npmignore or files field.
2. Add `scripts` in package.json: `test`, `prepublish:verify` (runs pack inspection).
3. Add GitHub Actions workflow `.github/workflows/publish-ansible-<name>.yml` (tag-triggered) that performs build/test/pack/publish and uses `secrets.NPM_TOKEN`.
4. Add a CI test `tests/test_package_json.js` or similar that asserts package.json readiness.
5. Document publish steps in the package README and top-level CONTRIBUTING or docs if desired.
## Open Questions
- What do you want the package name to be under the @ansible scope? (I'll assume `@ansible/example` and proceed; changeable later.)
- Do you want the package to be public or private? (I assumed **public**.)
- Do you prefer versioning via git tags (recommended) or manual package.json bumps?
I'm proceeding to create the design doc file in the repo and commit it. Interrupt if you want any changes to the scope above before I continue to the implementation planning step.

@ -2,164 +2,122 @@
**Date:** 2026-03-26
**Subdomain:** `motief.sgeboers.nl`
**Stack:** Streamlit · DuckDB · Docker · Nginx · Drone CI
**Stack:** Streamlit · uv · systemd · Nginx · Drone CI
**Target:** VPS, `webapps` user at `/home/webapps/motief/`
---
## What's already ready (no changes needed)
## Already done ✅
- `Dockerfile` — builds `streamlit run Home.py --server.port=8501`
- `docker-compose.yml``motief` + `scheduler` services, `DATA_DIR` env override
- `.drone.yml` — builds image, pushes to registry, SSH-deploys on push to `main`
- `Home.py`, `pages/1_Stemwijzer.py`, `pages/2_Explorer.py` — all exist
- VPS directory `/home/webapps/motief/data/` created
- `motions.db` uploaded to VPS
- nginx vhost configured for `motief.sgeboers.nl`
- TLS cert via certbot
---
## Step A — VPS: one-time directory setup
## Step A — Install uv on VPS
SSH in as `webapps`:
```bash
mkdir -p /home/webapps/motief/data
curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.cargo/env # or re-login
uv --version # verify
```
Create `/home/webapps/motief/.env`:
```env
DOCKER_REGISTRY=<your-registry-url>
DOCKER_USERNAME=<registry-user>
DOCKER_PASSWORD=<registry-password>
OPENROUTER_API_KEY=<key>
OPENAI_API_KEY=<key>
```
Copy `docker-compose.yml` into place:
```bash
# From local machine
scp docker-compose.yml webapps@<vps>:/home/webapps/motief/
```
Or just clone the repo there and symlink — either works since Drone will overwrite it.
---
## Step B — Transfer the database
From local machine (~4 GB, takes a few minutes):
## Step B — Clone repo and install dependencies
```bash
rsync -avz --progress data/motions.db webapps@<vps>:/home/webapps/motief/data/motions.db
cd /home/webapps
git clone <your-gitea-url>/sgeboers/stemwijzer motief
cd motief
uv sync
```
Do this as close to go-live as possible so the data isn't stale on launch.
The `motions.db` you already uploaded should live at:
```
/home/webapps/motief/data/motions.db
```
---
## Step C — DNS
## Step C — Create systemd user service
Add an **A record** in your DNS provider:
Create `~/.config/systemd/user/motief.service`:
```
stematlas → (obsolete, skip)
motief → <VPS IPv4>
```
```ini
[Unit]
Description=motief.sgeboers.nl Streamlit app
After=network.target
TTL 300 for the first deploy so you can iterate quickly; bump to 3600 after it's stable.
[Service]
WorkingDirectory=/home/webapps/motief
ExecStart=/home/webapps/.local/bin/uv run streamlit run Home.py --server.port=8501 --server.headless=true
Restart=on-failure
RestartSec=5
---
## Step D — Nginx vhost
Create `/etc/nginx/sites-available/motief`:
```nginx
server {
listen 80;
server_name motief.sgeboers.nl;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name motief.sgeboers.nl;
ssl_certificate /etc/letsencrypt/live/motief.sgeboers.nl/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/motief.sgeboers.nl/privkey.pem;
# Streamlit requires WebSocket upgrade for live updates
location / {
proxy_pass http://127.0.0.1:8501;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
}
}
[Install]
WantedBy=default.target
```
Enable and reload:
Enable and start:
```bash
sudo ln -s /etc/nginx/sites-available/motief /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
systemctl --user daemon-reload
systemctl --user enable motief
systemctl --user start motief
systemctl --user status motief # verify it's running
```
---
## Step E — TLS cert
Enable linger so the service survives logout:
```bash
sudo certbot --nginx -d motief.sgeboers.nl
# Needs sudo (once only)
sudo loginctl enable-linger webapps
```
(Assumes Certbot is already installed and working for other subdomains.)
---
## Step F — Configure Drone secrets
## Step D — Configure Drone secrets
In the Gitea/Drone repo settings for `sgeboers/stemwijzer`, add:
In `drone.sgeboers.nl``sgeboers/stemwijzer`**Settings → Secrets**:
| Secret | Value |
|--------|-------|
| `DOCKER_REGISTRY` | Your registry URL |
| `DOCKER_USERNAME` | Registry login |
| `DOCKER_PASSWORD` | Registry password |
| `DEPLOY_HOST` | VPS hostname/IP |
| `DEPLOY_SSH_PORT` | SSH port (usually 22) |
| `DEPLOY_HOST` | VPS hostname or IP |
| `DEPLOY_SSH_PORT` | `22` (or custom) |
| `DEPLOY_USER` | `webapps` |
| `DEPLOY_PASSWORD` | webapps SSH password |
---
## Step G — First deploy
## Step E — First auto-deploy
Option 1 — trigger Drone automatically:
```bash
git push origin main
```
Drone builds → pushes image → SSH into VPS → `docker-compose up -d`.
Option 2 — manual first deploy (on VPS):
Drone will SSH in and run:
```bash
cd /home/webapps/motief
docker-compose pull
docker-compose up -d
git pull origin main
uv sync
systemctl --user restart motief
```
---
## Step H — Verify
## Step F — Verify
```bash
# On VPS
docker-compose -f /home/webapps/motief/docker-compose.yml logs -f motief
systemctl --user status motief
journalctl --user -u motief -f
# From local browser
# From browser
open https://motief.sgeboers.nl
```
@ -168,22 +126,27 @@ Checklist:
- [ ] Compass tab renders with correct party positions (GL-PvdA top-left, PVV bottom-right)
- [ ] SVD tab scree plot shows with highlighted top-2 bars
- [ ] Similarity search returns results
- [ ] Scheduler container is running (`docker-compose ps`)
---
## Ongoing: data updates
The `scheduler` service runs the weekly pipeline inside the container:
- Scrapes new motions from the TK OData API
- Re-embeds new motion text via OpenRouter
- Updates similarity cache
The `scheduler.py` can be run as a separate user service or a cron job. To set it up as a service:
The `motions.db` file on the VPS is the single source of truth — it's bind-mounted into both containers. No cron job needed on the host.
Create `~/.config/systemd/user/motief-scheduler.service`:
If you ever need to force a full re-run:
```bash
docker-compose exec scheduler python pipeline/run_pipeline.py --db-path data/motions.db
```ini
[Unit]
Description=motief scheduler (weekly pipeline)
After=network.target
[Service]
WorkingDirectory=/home/webapps/motief
ExecStart=/home/webapps/.local/bin/uv run python scheduler.py
Restart=on-failure
[Install]
WantedBy=default.target
```
---
@ -191,12 +154,9 @@ docker-compose exec scheduler python pipeline/run_pipeline.py --db-path data/mot
## Dependency order
```
A (dirs + .env) ─┐
B (rsync DB) ─┤─► G (first deploy) ─► H (verify)
C (DNS) ─┤
D (nginx) ─┤
E (certbot) ─┘
F (Drone secrets) ──► future auto-deploys on push to main
A (install uv) ─┐
B (clone + sync) ─┤─► C (systemd service) ─► E (push to main) ─► F (verify)
└─► D (Drone secrets) ────┘
```
Steps A–F can all be done in one SSH session. Total estimated time: **45 minutes** (mostly waiting on rsync).
Total estimated time: **20 minutes**.

@ -0,0 +1,269 @@
# Rewrite @ansible package for npm publish — Implementation Plan
**Goal:** Implement a publishable npm-scoped example package at packages/@ansible/example, add guarded GitHub Actions publish and deploy workflows (publish on v* tags or manual_dispatch; deploy to motief.sgeboers.nl via user webapps), CI pack-tests, and documentation for embedding and deployment secrets.
**Design:** see thoughts/shared/designs/2026-03-28-rewrite-ansible-package-design.md
Author: Sven Geboers
---
## Dependency Graph
```
Batch 1 (parallel): 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7
Batch 2 (parallel): 2.1, 2.2, 2.3, 2.4 [depends on Batch 1]
```
---
## Batch 1: Foundation (parallel - independent files)
All tasks in this batch have NO dependencies and can be created in parallel.
### Task 1.1: package.json for package
**File:** `packages/@ansible/example/package.json`
**Summary:** New package.json implementing an npm-scoped package @ansible/example. Exact choices made:
- name: "@ansible/example"
- version: "0.1.0"
- author: "Sven Geboers"
- publishConfig.access: "public"
- files: ["src/**", "README.md", "package.json"] (controls published files, avoids .npmignore)
- scripts:
- test: `node tests/run.js`
- pack-inspect: `node tests/test_pack_inspect.js`
- prepublish:verify: `npm run pack-inspect` (keeps a named script for CI)
- pack: `npm pack`
**Tests to add:** none in this file; tests below will exercise package.json
**Verify:**
- Local: cd packages/@ansible/example && node -e "console.log(require('./package.json').name)" # should print @ansible/example
- Run package tests: cd packages/@ansible/example && npm test
**Effort:** 0.5h
### Task 1.2: README.md
**File:** `packages/@ansible/example/README.md`
**Summary:** Short README with package purpose, usage example, and author attribution line "Author: Sven Geboers". Include publish and deploy notes and reference to docs/*.md for deployment details.
**Tests to add:** none
**Verify:** open file or run: sed -n '1,40p' packages/@ansible/example/README.md
**Effort:** 0.5h
### Task 1.3: package entrypoint
**File:** `packages/@ansible/example/src/index.js`
**Summary:** Minimal, well-documented CommonJS module that exports a function used by tests. Keep runtime trivial (e.g., function hello(name){ return `hello ${name}` }). No dependencies.
**Tests to add:** used by unit test below
**Verify:** node -e "console.log(require('./packages/@ansible/example/src/index.js')('world'))"
**Effort:** 0.5h
### Task 1.4: unit test — package.json fields
**File:** `packages/@ansible/example/tests/test_package_json.js`
**Summary:** Node test that loads package.json and asserts required fields are present: name === "@ansible/example", version present, author === "Sven Geboers", publishConfig.access === "public", files array exists.
**Tests to add:** this is the test file
**Verify:** cd packages/@ansible/example && node tests/test_package_json.js (exit 0 on success)
**Effort:** 0.5h
### Task 1.5: pack-inspect test
**File:** `packages/@ansible/example/tests/test_pack_inspect.js`
**Summary:** Test that runs `npm pack` (in package dir) programmatically, captures the produced tarball name, asserts the tarball exists and contains package/package.json. Implementation notes: uses child_process.execSync and `tar -xOzf` to read package/package.json from the tarball and assert name/version match. This test requires `tar` on the runner (Linux/macOS). If `tar` not available, the test fails with clear message.
**Tests to add:** this is the test
**Verify:** cd packages/@ansible/example && node tests/test_pack_inspect.js
**Effort:** 1.0h
### Task 1.6: tiny package test runner
**File:** `packages/@ansible/example/tests/run.js`
**Summary:** Small node test harness that runs both test_package_json.js and test_pack_inspect.js, prints nice output, and returns non-zero on failure. Used by package.json test script to keep CI independent of external test runners.
**Tests to add:** none (this is the runner used by `npm test`)
**Verify:** cd packages/@ansible/example && node tests/run.js
**Effort:** 0.5h
### Task 1.7: pack-inspect helper (optional small script)
**File:** `packages/@ansible/example/tests/_pack_helpers.js`
**Summary:** Small utility used by test_pack_inspect.js to encapsulate npm pack and tar inspection logic. Keeps main test readable. (Kept internal/private to tests.)
**Tests to add:** none
**Verify:** run the tests which import it
**Effort:** 0.25h
---
## Batch 2: CI + Docs (parallel — depend on Batch 1)
All tasks in this batch depend on the package files being present (Batch 1).
### Task 2.1: GitHub Actions publish workflow
**File:** `.github/workflows/publish-ansible-example.yml`
**Summary:** New Actions workflow that performs build/test/pack/publish for packages/@ansible/example.
**Key behavior:**
- Triggers: push tags matching `v*` and workflow_dispatch (manual).
- Jobs:
- verify: runs on all triggers: checks out repo, sets up Node 18 (LTS) using actions/setup-node, installs root-level dependencies if any (skipped if none), then runs `cd packages/@ansible/example && npm ci || true` (guard), then `npm test`. Then runs `npm pack` and verifies produced tarball (reuses test script). The verify job always runs and must pass before publish.
- publish: runs only when `github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')` OR when manually triggered AND `secrets.NPM_TOKEN` exists. publish job is gated by `if: ${{ secrets.NPM_TOKEN != '' && ( github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') ) }}`.
- On publish: write ephemeral ~/.npmrc with `//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}` (use `echo` and file permissions 0600), run `npm publish --access public` from package directory, then securely delete ~/.npmrc (shred if available or overwrite and rm). Use Actions mask to avoid logging secrets.
**Secrets required:** NPM_TOKEN (string)
**Tests to add:** none (workflow file)
**Verify:**
- Locally simulate: cd packages/@ansible/example && npm test && npm pack
- In Actions: push a non-production tag on a test repository with a safe NPM_TOKEN that points to a test registry OR run workflow_dispatch with a dry-run branch (see dry-run below)
**Effort:** 1.5h
### Task 2.2: GitHub Actions deploy workflow
**File:** `.github/workflows/deploy-to-vps.yml`
**Summary:** Workflow to prepare and run deployment to external VPS motief.sgeboers.nl using user `webapps`. Triggers: push to main and workflow_dispatch. The job is non-destructive by default and will not run remote commands unless DEPLOY_SSH_KEY or DEPLOY_PASSWORD secrets are present. It includes a dry-run/verify job that checks SSH connectivity without executing commands that change state.
**Key behavior:**
- Inputs/secrets used (recommended names): DEPLOY_HOST (default motief.sgeboers.nl), DEPLOY_USER (default webapps), DEPLOY_SSH_PORT (default 22), DEPLOY_SSH_KEY (private key, optional), DEPLOY_PASSWORD (optional fallback), DEPLOY_PATH (path to deploy, optional)
- Jobs:
- dry-run-connect: attempts to verify connectivity. If DEPLOY_SSH_KEY present, create an ephemeral key file (0600), run `ssh -o BatchMode=yes -i $KEY -p $PORT $USER@$HOST 'echo connected'` and return success if the host echoes. This step is strictly a connection check (no file writes). If only DEPLOY_PASSWORD provided, the job will print instructions explaining the need for SSH key or use of a runner with sshpass (not recommended) and skip.
- deploy: guarded `if: steps.check_secrets.outputs.has_creds == 'true'` — only runs when credentials are present. Steps: checkout, optionally build artifacts, create ephemeral SSH key file, rsync or scp artifact to $USER@$HOST:$DEPLOY_PATH, run non-destructive remote commands (e.g., systemctl --user status <unit> or echo) only if DEPLOY_CONFIRM=true input is set. By default the job performs no destructive operations; it's expected operator sets inputs when ready.
**Secrets required (for actual deploy):** DEPLOY_SSH_KEY (preferred), OR DEPLOY_PASSWORD (less secure, not recommended)
**Secrets recommended / env:** DEPLOY_HOST (default motief.sgeboers.nl), DEPLOY_USER=webapps, DEPLOY_SSH_PORT=22, DEPLOY_PATH=/home/webapps/motief (example)
**Tests to add:** none (workflow file)
**Verify:**
- Run dry-run via workflow_dispatch on Actions to verify connectivity (without running deploy). Ensure secrets are set in repository settings.
**Effort:** 2.0h
### Task 2.3: docs — deployment and Drone note
**File:** `docs/deployment/ansible-package-deploy.md`
**Summary:** Documentation describing: default host motief.sgeboers.nl, recommended DEPLOY_USER webapps, how to add GitHub repo secrets (DEPLOY_SSH_KEY, DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_PORT), sample systemd unit name recommendation (e.g., service name `motief`), and instructions for users preferring Drone: how to set equivalent secrets in Drone and a note that .drone.yml is left untouched.
**Tests to add:** none
**Verify:** open file or grep keywords DEPLOY_SSH_KEY, motief.sgeboers.nl
**Effort:** 0.75h
### Task 2.4: docs — embeddings and environment variables
**File:** `docs/embeddings.md`
**Summary:** Document that the project uses QWEN embeddings (qwen/qwen3-embedding-4b via OpenRouter), recommend setting OPENROUTER_API_KEY in repo/host secrets, and mention OPENAI_API_KEY as optional fallback. Include example env var usage and security guidance.
**Tests to add:** none
**Verify:** open file and confirm environment variables documented
**Effort:** 0.5h
---
## CI workflow outlines (YAML-level steps in prose)
Publish workflow (.github/workflows/publish-ansible-example.yml):
- on:
- push: tags: ['v*']
- workflow_dispatch
- jobs:
- verify:
- runs-on: ubuntu-latest
- steps:
1. actions/checkout@v4
2. actions/setup-node@v4 (node-version: '18') with cache: 'npm'
3. echo current package info (for debugging) but do NOT print secrets
4. cd packages/@ansible/example && npm ci --no-audit --prefer-offline || true
5. cd packages/@ansible/example && npm test (calls tests/run.js)
6. cd packages/@ansible/example && npm run pack-inspect
- artifacts: upload pack artifact for inspection (optional)
- publish:
- needs: verify
- runs-on: ubuntu-latest
- if: (github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')) && secrets.NPM_TOKEN != ''
- steps:
1. checkout
2. setup-node
3. create ephemeral ~/.npmrc with content `//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}` using `run: printf` and set 0600
4. cd packages/@ansible/example && npm publish --access public
5. securely remove ~/.npmrc (overwrite if shred available then rm)
- secrets required: NPM_TOKEN
- guard rails: job will not run if NPM_TOKEN is empty; Actions expressions used to gate execution
Deploy workflow (.github/workflows/deploy-to-vps.yml):
- on:
- push: branches: [ main ]
- workflow_dispatch
- inputs (for manual dispatch): target_branch, confirm_deploy (boolean)
- jobs:
- dry-run-connect:
- runs-on: ubuntu-latest
- steps:
1. checkout
2. set env from secrets (DEPLOY_HOST=motief.sgeboers.nl default)
3. if secrets.DEPLOY_SSH_KEY set: write ephemeral key file with 0600 and run ssh -o BatchMode=yes -p $DEPLOY_SSH_PORT $DEPLOY_USER@$DEPLOY_HOST 'echo connected' (short timeout)
4. if no key present but DEPLOY_PASSWORD present: skip and print instructions to set key
- outcome: outputs.has_creds true/false for next job
- deploy:
- needs: dry-run-connect
- if: needs.dry-run-connect.outputs.has_creds == 'true' && github.event.inputs.confirm_deploy == 'true'
- steps:
1. checkout
2. build artifacts (if required)
3. create ephemeral key file and copy artifacts via rsync/scp to $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH
4. optionally run remote non-destructive checks (e.g., tail logs, systemctl --user status motief)
- secrets required for actual deploy: DEPLOY_SSH_KEY or DEPLOY_PASSWORD
Security considerations for workflows:
- Never echo secrets. Use Actions' built-in mask and avoid printing environment variables that contain secrets.
- Create ephemeral ~/.npmrc and remove it immediately. Use file permissions 0600.
- Create ephemeral SSH key files with 0600 and remove them at the end of the job.
---
## Rollback / Undo plan for risky changes
- Publish workflow added: to rollback, remove or disable `.github/workflows/publish-ansible-example.yml` and push a commit. If a package was accidentally published, use npm unpublish only if within npm policy window (careful: unpublish can harm consumers) — prefer deprecating the published version via `npm deprecate @ansible/example@x.y.z "do not use"`.
- Deploy workflow added: to rollback, remove/disable `.github/workflows/deploy-to-vps.yml`. If deployment ran, have a documented process on the VPS to rollback the app (e.g., systemd service `motief` reversion, keep previous release tarball and symlink rollback). Document in deploy docs how to revert symlink/munpack and restart systemd.
- Docs changed: simply revert doc files and commit.
Notes on npm unpublish: prefer `npm deprecate` over unpublish in most cases. Unpublish should be used only with caution and awareness of npm registry policy.
---
## Final operator checklist (manual steps before running publish/deploy)
1. Add repository secrets in GitHub Settings > Secrets:
- NPM_TOKEN — a token with publish access to the @ansible scope (required for publish job)
- DEPLOY_SSH_KEY — (recommended) private SSH key for `webapps` user on motief.sgeboers.nl; set as secret (do NOT include passphrase unless CI knows how to handle it)
- Optionally DEPLOY_PASSWORD — password fallback (not recommended)
- DEPLOY_HOST — default: motief.sgeboers.nl (recommended to set explicitly)
- DEPLOY_USER — default: webapps
- DEPLOY_SSH_PORT — default: 22
- OPENROUTER_API_KEY — recommended for embeddings
- OPENAI_API_KEY — optional fallback
2. Ensure target VPS `motief.sgeboers.nl` has user `webapps` configured and an authorized public key corresponding to the DEPLOY_SSH_KEY private key.
3. Ensure the VPS has a systemd unit name prepared (recommendation: `motief.service`) and that deployment user `webapps` may write to the deploy path (e.g., /home/webapps/motief) and manage its own files. Documented in `docs/deployment/ansible-package-deploy.md`.
4. (Optional) If you prefer Drone CI: set Drone secrets for NPM_TOKEN and DEPLOY_* equivalently; .drone.yml remains untouched and you can run publish steps in Drone if you adapt the template.
5. Verify local tests: from repo root run:
- cd packages/@ansible/example && npm test
- cd packages/@ansible/example && npm pack && tar -tzf <produced-tgz> | head -n 20
6. For publish: create a tag `git tag v0.1.0` (or desired semver) and push the tag. The publish workflow will run and attempt publish if NPM_TOKEN secret is present.
7. For deploy: run the deploy workflow_dispatch after setting DEPLOY_SSH_KEY and testing dry-run connectivity via the dry-run job.
---
## Decisions & Assumptions
- Package name chosen: `@ansible/example` per design doc. Changeable later by editing package.json and tag.
- Default version set to `0.1.0` to indicate initial publish candidate.
- Testing uses a zero-dependency Node test harness (simple node scripts) to avoid introducing a test framework and to keep CI minimal.
- SSH key auth chosen as default for deployment because it's more secure and scriptable in CI than password auth. SSH keys avoid exposing passwords in secrets logs and work with `ssh -o BatchMode=yes` checks. We recommend DEPLOY_SSH_KEY; DEPLOY_PASSWORD is supported as a fallback but not recommended.
- Workflows are deliberately gated: publish runs only on tags `v*` or manual dispatch, and publish job requires NPM_TOKEN secret. Deploy will not execute destructive commands unless manual confirm is provided in workflow_dispatch inputs.
- We will not modify existing `.drone.yml` as requested; docs will describe how to set Drone secrets if the operator prefers Drone.
---
## Staged execution order
1. Batch 1 (create package files & tests): tasks 1.1 — 1.7 (all parallel if multiple implementers available)
2. Batch 2 (CI & docs): tasks 2.1 — 2.4 (parallel after Batch 1 complete)
---
## Estimates summary
- Batch 1 total: ~3.75h
- Batch 2 total: ~4.75h
- Grand total: ~8.5h (approx)
---
Write this plan to: `thoughts/shared/plans/2026-03-28-rewrite-ansible-package.md`
Done.
Loading…
Cancel
Save