name: Push JSON changes to PocketBase on: issues: types: - labeled workflow_dispatch: inputs: script_slug: description: "Script slug (e.g. my-app)" required: true type: string jobs: push-json: runs-on: self-hosted if: >- github.event_name == 'workflow_dispatch' || (github.event_name == 'issues' && contains(github.event.issue.labels.*.name, 'Ready For Testing')) steps: - name: Checkout Repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Get JSON file for script id: changed run: | : > changed_app_jsons.txt if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then script_slug="${{ github.event.inputs.script_slug }}" if [[ "$script_slug" == "*" || "$script_slug" == "all" ]]; then count=0 for f in json/*.json; do [[ -f "$f" ]] || continue jq -e '.slug' "$f" >/dev/null 2>&1 || continue echo "$f" >> changed_app_jsons.txt count=$((count + 1)) done echo "count=${count}" >> "$GITHUB_OUTPUT" echo "Queued ${count} JSON files for push." exit 0 fi file="json/${script_slug}.json" if [[ ! -f "$file" ]]; then echo "No JSON file at $file." echo "count=0" >> "$GITHUB_OUTPUT" exit 0 fi if ! jq -e '.slug' "$file" >/dev/null 2>&1; then echo "File $file has no .slug." echo "count=0" >> "$GITHUB_OUTPUT" exit 0 fi echo "$file" > changed_app_jsons.txt echo "count=1" >> "$GITHUB_OUTPUT" exit 0 fi # Extract slug from issue title (lowercase, spaces to dashes) ISSUE_TITLE="${{ github.event.issue.title }}" script_slug=$(echo "$ISSUE_TITLE" | tr '[:upper:]' '[:lower:]' | sed 's/ /-/g') file="json/${script_slug}.json" if [[ ! -f "$file" ]]; then echo "No JSON file at $file for issue '${ISSUE_TITLE}'." echo "count=0" >> "$GITHUB_OUTPUT" exit 0 fi if ! jq -e '.slug' "$file" >/dev/null 2>&1; then echo "File $file has no .slug." echo "count=0" >> "$GITHUB_OUTPUT" exit 0 fi echo "$file" > changed_app_jsons.txt echo "count=1" >> "$GITHUB_OUTPUT" - name: Push to PocketBase if: steps.changed.outputs.count != '0' env: POCKETBASE_URL: ${{ secrets.POCKETBASE_URL }} POCKETBASE_COLLECTION: ${{ secrets.POCKETBASE_COLLECTION }} POCKETBASE_ADMIN_EMAIL: ${{ secrets.POCKETBASE_ADMIN_EMAIL }} POCKETBASE_ADMIN_PASSWORD: ${{ secrets.POCKETBASE_ADMIN_PASSWORD }} run: | node << 'ENDSCRIPT' (async function() { const fs = require('fs'); const https = require('https'); const http = require('http'); const url = require('url'); function request(fullUrl, opts, redirectCount) { redirectCount = redirectCount || 0; return new Promise(function(resolve, reject) { const u = url.parse(fullUrl); const isHttps = u.protocol === 'https:'; const body = opts.body; const options = { hostname: u.hostname, port: u.port || (isHttps ? 443 : 80), path: u.path, method: opts.method || 'GET', headers: opts.headers || {} }; if (body) options.headers['Content-Length'] = Buffer.byteLength(body); const lib = isHttps ? https : http; const req = lib.request(options, function(res) { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { if (redirectCount >= 5) return reject(new Error('Too many redirects from ' + fullUrl)); const redirectUrl = url.resolve(fullUrl, res.headers.location); res.resume(); resolve(request(redirectUrl, opts, redirectCount + 1)); return; } let data = ''; res.on('data', function(chunk) { data += chunk; }); res.on('end', function() { resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, statusCode: res.statusCode, body: data }); }); }); req.on('error', reject); if (body) req.write(body); req.end(); }); } const raw = process.env.POCKETBASE_URL.replace(/\/$/, ''); const apiBase = /\/api$/i.test(raw) ? raw : raw + '/api'; const coll = process.env.POCKETBASE_COLLECTION; const files = fs.readFileSync('changed_app_jsons.txt', 'utf8').trim().split(/\s+/).filter(Boolean); const authUrl = apiBase + '/collections/users/auth-with-password'; console.log('Auth URL: ' + authUrl); const authBody = JSON.stringify({ identity: process.env.POCKETBASE_ADMIN_EMAIL, password: process.env.POCKETBASE_ADMIN_PASSWORD }); const authRes = await request(authUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: authBody }); if (!authRes.ok) { throw new Error('Auth failed. Tried: ' + authUrl + ' - Verify POST to that URL with body {"identity":"...","password":"..."} works. Response: ' + authRes.body); } const token = JSON.parse(authRes.body).token; const recordsUrl = apiBase + '/collections/' + encodeURIComponent(coll) + '/records'; let categoryIdToName = {}; try { const metadata = JSON.parse(fs.readFileSync('json/metadata.json', 'utf8')); (metadata.categories || []).forEach(function(cat) { categoryIdToName[cat.id] = cat.name; }); } catch (e) { console.warn('Could not load metadata.json:', e.message); } let typeValueToId = {}; let categoryNameToPbId = {}; try { const typesRes = await request(apiBase + '/collections/z_ref_script_types/records?perPage=500', { headers: { 'Authorization': token } }); if (typesRes.ok) { const typesData = JSON.parse(typesRes.body); (typesData.items || []).forEach(function(item) { if (item.type != null) typeValueToId[item.type] = item.id; if (item.name != null) typeValueToId[item.name] = item.id; if (item.value != null) typeValueToId[item.value] = item.id; }); } } catch (e) { console.warn('Could not fetch z_ref_script_types:', e.message); } try { const catRes = await request(apiBase + '/collections/script_categories/records?perPage=500', { headers: { 'Authorization': token } }); if (catRes.ok) { const catData = JSON.parse(catRes.body); (catData.items || []).forEach(function(item) { if (item.name) categoryNameToPbId[item.name] = item.id; }); } } catch (e) { console.warn('Could not fetch script_categories:', e.message); } for (const file of files) { if (!fs.existsSync(file)) continue; const data = JSON.parse(fs.readFileSync(file, 'utf8')); if (!data.slug) { console.log('Skipping', file, '(no slug)'); continue; } // execute_in: map type to canonical value (addon runs on both lxc and vm) var executeInMap = { ct: 'lxc', lxc: 'lxc', turnkey: 'turnkey', pve: 'pve', vm: 'vm', addon: ['lxc', 'vm'] }; var executeIn = data.type ? (executeInMap[data.type.toLowerCase()] || null) : null; // github: extract owner/repo from full GitHub URL var githubField = null; if (data.github) { var ghMatch = data.github.match(/github\.com\/([^/]+\/[^/?#]+)/); if (ghMatch) githubField = ghMatch[1].replace(/\.git$/, ''); } // last_update_commit: last commit touching the actual script files (ct/slug.sh, install/slug-install.sh, vm/slug.sh, etc.) var lastCommit = null; try { var cp = require('child_process'); var scriptFiles = []; // primary script from install_methods[].script (e.g. "ct/teleport.sh", "vm/teleport.sh") (data.install_methods || []).forEach(function(im) { if (im.script) scriptFiles.push(im.script); }); // derive install script from slug (install/slug-install.sh) scriptFiles.push('install/' + data.slug + '-install.sh'); // filter to only files that actually exist in git var existingFiles = scriptFiles.filter(function(f) { try { cp.execSync('git ls-files --error-unmatch ' + f, { stdio: 'ignore' }); return true; } catch(e) { return false; } }); if (existingFiles.length > 0) { lastCommit = cp.execSync('git log -1 --format=%H -- ' + existingFiles.join(' ')).toString().trim() || null; } } catch(e) { console.warn('Could not get last commit:', e.message); } var payload = { name: data.name, slug: data.slug, script_created: data.date_created || data.script_created, script_updated: new Date().toISOString().split('T')[0], updateable: data.updateable, privileged: data.privileged, port: data.interface_port != null ? data.interface_port : data.port, documentation: data.documentation, website: data.website, logo: data.logo, description: data.description, default_user: (data.default_credentials && data.default_credentials.username) || data.default_user || null, default_passwd: (data.default_credentials && data.default_credentials.password) || data.default_passwd || null, notes: data.notes || [], install_methods: data.install_methods || [], is_dev: true }; if (executeIn) payload.execute_in = executeIn; if (githubField) payload.github = githubField; if (lastCommit) payload.last_update_commit = lastCommit; var resolvedType = typeValueToId[data.type]; if (resolvedType == null && data.type === 'ct') resolvedType = typeValueToId['lxc']; if (resolvedType) payload.type = resolvedType; var resolvedCats = (data.categories || []).map(function(n) { return categoryNameToPbId[categoryIdToName[n]]; }).filter(Boolean); if (resolvedCats.length) payload.categories = resolvedCats; if (data.has_arm !== undefined) payload.has_arm = data.has_arm === true || data.has_arm === 'true'; if (data.version !== undefined) payload.version = data.version; if (data.changelog !== undefined) payload.changelog = data.changelog; if (data.screenshots !== undefined) payload.screenshots = data.screenshots; const filter = "(slug='" + data.slug + "')"; const listRes = await request(recordsUrl + '?filter=' + encodeURIComponent(filter) + '&perPage=1', { headers: { 'Authorization': token } }); const list = JSON.parse(listRes.body); const existingId = list.items && list.items[0] && list.items[0].id; if (existingId) { console.log('Updating', file, '(slug=' + data.slug + ')'); const r = await request(recordsUrl + '/' + existingId, { method: 'PATCH', headers: { 'Authorization': token, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!r.ok) throw new Error('PATCH failed: ' + r.body); } else { console.log('Creating', file, '(slug=' + data.slug + ')'); const r = await request(recordsUrl, { method: 'POST', headers: { 'Authorization': token, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!r.ok) throw new Error('POST failed: ' + r.body); } } console.log('Done.'); })().catch(e => { console.error(e); process.exit(1); }); ENDSCRIPT shell: bash - name: Revalidate Frontend Cache if: steps.changed.outputs.count != '0' env: FRONTEND_URL: ${{ secrets.FRONTEND_URL }} REVALIDATE_SECRET: ${{ secrets.REVALIDATE_SECRET }} run: | if [[ -z "$FRONTEND_URL" || -z "$REVALIDATE_SECRET" ]]; then echo "FRONTEND_URL or REVALIDATE_SECRET not set, skipping revalidation." exit 0 fi SLUGS=$(cat changed_app_jsons.txt | xargs -I{} basename {} .json | jq -R -s -c 'split("\n") | map(select(length > 0))') echo "Revalidating tags: scripts, categories — slugs: $SLUGS" curl -sf -X POST "${FRONTEND_URL}/api/revalidate" \ -H "Authorization: Bearer ${REVALIDATE_SECRET}" \ -H "Content-Type: application/json" \ -d "{\"tags\":[\"scripts\",\"categories\"],\"slugs\":${SLUGS}}" \ && echo "Cache revalidated." \ || echo "Warning: revalidation failed (non-critical)."