Dockhand #616
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)." |