diff --git a/.github/workflows/beta-server-image.yml b/.github/workflows/beta-server-image.yml index 1b70b9b..adc8950 100644 --- a/.github/workflows/beta-server-image.yml +++ b/.github/workflows/beta-server-image.yml @@ -4,7 +4,7 @@ name: Beta Server Image # feature branch can be deployed to a staging/backup server and used as a custom # server, without ever touching the ':latest' tag the official relay tracks. # -# Tags produced (on ghcr.io//): +# Tags produced (on ghcr.io/shik3i/koalasync): # - beta moving channel pointer to the newest build # - e.g. feature-textchat # - sha- immutable, pin to an exact build @@ -73,7 +73,7 @@ jobs: id: meta uses: docker/metadata-action@v6 with: - images: ghcr.io/${{ github.repository }} + images: ghcr.io/shik3i/koalasync # Never publish ':latest' from a beta build. flavor: | latest=false diff --git a/scripts/release-local-gate.mjs b/scripts/release-local-gate.mjs index c80f67a..9e7d12b 100644 --- a/scripts/release-local-gate.mjs +++ b/scripts/release-local-gate.mjs @@ -108,6 +108,22 @@ export function validateReleaseWorkflowContract(text) { return image; } +export function validateWorkflowImageReferences(workflows) { + const canonicalImage = 'ghcr.io/shik3i/koalasync'; + for (const [name, rawText] of Object.entries(workflows)) { + const text = String(rawText); + if (/ghcr\.io\/\$\{\{\s*github\.repository\s*\}\}/iu.test(text)) { + throw new Error(`${name} must not derive a Docker image from case-preserving github.repository`); + } + for (const reference of text.match(/ghcr\.io\/[a-zA-Z0-9._/-]+/gu) || []) { + if (reference !== canonicalImage) { + throw new Error(`${name} must use the lowercase canonical image ${canonicalImage}, found ${reference}`); + } + } + } + return canonicalImage; +} + function assertCleanTree() { const status = capture('git', ['status', '--porcelain=v1']); if (status) throw new Error(`release gate requires a clean working tree:\n${status}`); @@ -163,6 +179,13 @@ async function smokeRelayImage(image) { export async function runReleaseGate({ version, candidate }) { assertCleanTree(); + const workflowDir = path.join(repoRoot, '.github', 'workflows'); + const workflowFiles = fs.readdirSync(workflowDir) + .filter(name => /\.ya?ml$/u.test(name)); + validateWorkflowImageReferences(Object.fromEntries(workflowFiles.map(name => [ + name, + fs.readFileSync(path.join(workflowDir, name), 'utf8') + ]))); validateReleaseWorkflowContract(fs.readFileSync( path.join(repoRoot, '.github/workflows/release.yml'), 'utf8' )); diff --git a/scripts/release-local-gate.test.mjs b/scripts/release-local-gate.test.mjs index 10e9f29..d25e274 100644 --- a/scripts/release-local-gate.test.mjs +++ b/scripts/release-local-gate.test.mjs @@ -5,7 +5,8 @@ import { parseGateArgs, parseRemoteMain, playwrightImageFromLock, - validateReleaseWorkflowContract + validateReleaseWorkflowContract, + validateWorkflowImageReferences } from './release-local-gate.mjs'; describe('local release gate contract', () => { @@ -60,6 +61,19 @@ describe('local release gate contract', () => { .toThrow('case-preserving github.repository'); }); + it('enforces the canonical lowercase image across every workflow', () => { + expect(validateWorkflowImageReferences({ + 'release.yml': 'IMAGE: ghcr.io/shik3i/koalasync', + 'beta.yml': 'images: ghcr.io/shik3i/koalasync:beta' + })).toBe('ghcr.io/shik3i/koalasync'); + expect(() => validateWorkflowImageReferences({ + 'beta.yml': 'images: ghcr.io/${{ github.repository }}' + })).toThrow('case-preserving github.repository'); + expect(() => validateWorkflowImageReferences({ + 'beta.yml': 'images: ghcr.io/Shik3i/KoalaSync' + })).toThrow('lowercase canonical image'); + }); + it('enforces the automatic version commit, direct push, prepared source, and final publication contract', () => { const workflow = fs.readFileSync('.github/workflows/release.yml', 'utf8'); expect(validateReleaseWorkflowContract(workflow)).toBe('ghcr.io/shik3i/koalasync');