Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/officers/STAGING_COMMUNITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ Officer review after the specialist confirms deployment:

**Undo:** stop the staging review. Ask Dave and the platform specialist to use the reviewed staging rollback or remove the one staged community service. Do not edit a member record, role, payment or production setting yourself.

The specialist's Hosting release uses Dave's existing club login for a short-lived credential kept in memory. It does not require a new saved Firebase CLI login or key file. A later documentation or release-script fix may reuse the Function only after its downloaded source exactly matches the reviewed bundle; it does not silently deploy more backend code.

**Escalation:** Dave/platform specialist for deployment, access, uncertain results or recovery. Future officer dues confirmation, event editing, paid registration/refunds, and production release require separate work. The specialist still provisions and removes test fixtures; officers do not use a terminal or change source.

## Cost and scope
Expand Down
33 changes: 28 additions & 5 deletions scripts/staging-community-hosting.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,44 @@
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { createHash } = require('node:crypto');
const { execFileSync } = require('node:child_process');
const { verifyCurrentSource } = require('./staging-profile-authority');
const { functionSpec, validateHostedFunction, jsonRequest } = require('./staging-community-release');
const { createBundle, functionSpec, validateHostedFunction, jsonRequest } = require('./staging-community-release');
const { verifyExecutableArtifact } = require('./firebase-hosting-contract');
const PROJECT = 'run-mprc-staging';
const ACCOUNT = 'runmprc@gmail.com';
const check = (value) => { if (!value) throw new Error('staging_community_hosting_failed'); };
function validateBundle(native, digest) {
check(`${native.labels?.['bundle-a']}${native.labels?.['bundle-b']}` === digest);
validateHostedFunction(native, functionSpec(native.labels?.['source-sha'], digest, 'https://storage.googleapis.com/synthetic'));
}
async function ownerToken() {
const token = execFileSync('/opt/homebrew/bin/gcloud', ['auth', 'print-access-token', `--account=${ACCOUNT}`, `--project=${PROJECT}`], { encoding: 'utf8' }).trim();
const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${token}` }, redirect: 'error', signal: AbortSignal.timeout(15000),
});
check(response.ok && (await response.json()).email === ACCOUNT);
return token; // No ADC fallback, persistent login, credential file or printed token.
}
async function publish() {
const root = path.resolve(__dirname, '..');
check(execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim() === '');
const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim();
const source = { GITHUB_SHA: sha, GH_TOKEN: execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim() };
await verifyCurrentSource(source);
const token = execFileSync('/opt/homebrew/bin/gcloud', ['auth', 'print-access-token', `--account=${ACCOUNT}`, `--project=${PROJECT}`], { encoding: 'utf8' }).trim();
const token = await ownerToken();
const native = await jsonRequest(`https://cloudfunctions.googleapis.com/v1/projects/${PROJECT}/locations/us-central1/functions/communityActions`, token);
const digest = `${native.labels?.['bundle-a']}${native.labels?.['bundle-b']}`;
validateHostedFunction(native, functionSpec(sha, digest, 'https://storage.googleapis.com/synthetic'));
const digest = createBundle(root).digest;
validateBundle(native, digest);
// Script/docs-only commits do not require another paid Function build. Require
// ancestor provenance AND native source bytes identical to the green main bundle.
execFileSync('git', ['merge-base', '--is-ancestor', native.labels['source-sha'], sha], { cwd: root });
const download = await jsonRequest(`https://cloudfunctions.googleapis.com/v1/${native.name}:generateDownloadUrl`, token, 'POST', {});
const archiveUrl = new URL(download.downloadUrl);
check(archiveUrl.protocol === 'https:' && archiveUrl.hostname === 'storage.googleapis.com');
const archive = await fetch(download.downloadUrl, { redirect: 'error', signal: AbortSignal.timeout(30000) });
check(archive.ok && createHash('sha256').update(Buffer.from(await archive.arrayBuffer())).digest('hex') === digest);
const configResponse = await fetch(`https://${PROJECT}.web.app/__/firebase/init.json`, { redirect: 'error', signal: AbortSignal.timeout(15000) });
check(configResponse.ok); const config = await configResponse.json();
check(config.projectId === PROJECT && typeof config.appId === 'string');
Expand Down Expand Up @@ -47,11 +68,13 @@ async function publish() {
fs.writeFileSync(filename, JSON.stringify({ hosting: { site: PROJECT, public: path.join(root, 'build'), ignore: ['firebase.json', '**/.*', '**/node_modules/**'], rewrites: [{ source: '**', destination: '/index.html' }] } }));
await verifyCurrentSource(source);
check(execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim() === '');
execFileSync(path.join(root, 'node_modules/.bin/firebase'), ['deploy', '--only', 'hosting', '--config', filename, '--project', PROJECT, '--account', ACCOUNT, '--non-interactive'], { cwd: root, env, stdio: 'pipe', timeout: 180000, maxBuffer: 2_000_000 });
const deployEnv = { ...env, FIREBASE_TOKEN: await ownerToken(), GOOGLE_CLOUD_QUOTA_PROJECT: PROJECT };
execFileSync(path.join(root, 'node_modules/.bin/firebase'), ['deploy', '--only', 'hosting', '--config', filename, '--project', PROJECT, '--non-interactive'], { cwd: root, env: deployEnv, stdio: 'pipe', timeout: 180000, maxBuffer: 2_000_000 });
const response = await fetch(`https://${PROJECT}.web.app/community-release.json`, { cache: 'no-store', redirect: 'error' });
check(response.ok); const marker = await response.json();
check(marker.sourceSha === sha && marker.project === PROJECT && marker.functionBundle === digest);
process.stdout.write(`staging_community_hosting_published_verified ${sha}\n`);
} finally { fs.rmSync(directory, { recursive: true }); }
}
if (require.main === module) publish().catch(() => { process.stderr.write('staging_community_hosting_failed\n'); process.exitCode = 1; });
module.exports = { validateBundle };
4 changes: 3 additions & 1 deletion scripts/staging-community-release.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ async function release(root) {
check(final.functions.length === 3 && !final.nextPageToken && !final.unreachable?.length);
for (const original of inventory.functions.filter((fn) => fn.entryPoint !== TARGET)) {
const unchanged = final.functions.find((fn) => fn.name === original.name);
check(unchanged?.versionId === original.versionId && JSON.stringify(unchanged.labels) === JSON.stringify(original.labels));
check(unchanged?.versionId === original.versionId
&& Object.keys(unchanged.labels || {}).length === Object.keys(original.labels || {}).length
&& Object.entries(original.labels || {}).every(([key, value]) => unchanged.labels[key] === value));
}
process.stdout.write(JSON.stringify({ status: 'staging_community_deployed_verified', sha, digest, bytes: bundle.zip.length }) + '\n');
}
Expand Down
7 changes: 7 additions & 0 deletions tests/staging-community-release.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,10 @@ test('profile release can inspect only the known isolated service, never deploy
assert.throws(() => validateKnownCommunity({ ...known, serviceAccountEmail: 'default' }));
assert.throws(() => profileSpec('communityActions', 'a'.repeat(40), 'b'.repeat(64), 'https://storage.googleapis.com/synthetic'));
});
test('Hosting requires the identical deployed Function bundle, not merely a label or newer commit', () => {
const { validateBundle } = require('../scripts/staging-community-hosting');
const native = { ...functionSpec('a'.repeat(40), 'b'.repeat(64), 'https://storage.googleapis.com/synthetic'), status: 'ACTIVE' };
assert.doesNotThrow(() => validateBundle(native, 'b'.repeat(64)));
assert.throws(() => validateBundle(native, 'c'.repeat(64)));
assert.throws(() => validateBundle({ ...native, maxInstances: 5 }, 'b'.repeat(64)));
});
Loading